diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d3467e..8aa5608 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - "v*" + - "v[0-9]*" workflow_dispatch: inputs: version: diff --git a/.github/workflows/store-diagnosis.yml b/.github/workflows/store-diagnosis.yml new file mode 100644 index 0000000..d0c06b7 --- /dev/null +++ b/.github/workflows/store-diagnosis.yml @@ -0,0 +1,46 @@ +name: Store read-only diagnosis + +on: + push: + branches: [develop] + paths: + - .github/workflows/store-diagnosis.yml + - tools/diagnose_store_submission.py + - tests/test_store_diagnosis.py + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + diagnose: + name: Inspect the existing Store draft without mutations + if: github.ref == 'refs/heads/develop' && github.sha == vars.OPENSTUDIO_STORE_DIAGNOSIS_SHA + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: microsoft-store + concurrency: + group: openstudio-microsoft-store-submission + cancel-in-progress: false + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Compare the fixed published baseline and failed release draft + env: + GITHUB_TOKEN: ${{ github.token }} + MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} + MS_STORE_CLIENT_ID: ${{ secrets.MS_STORE_CLIENT_ID }} + MS_STORE_CLIENT_SECRET: ${{ secrets.MS_STORE_CLIENT_SECRET }} + run: python3 tools/diagnose_store_submission.py --report output/store-diagnosis.json + + - name: Retain the sanitized read-only comparison + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: microsoft-store-read-only-diagnosis-${{ github.run_attempt }} + path: output/store-diagnosis.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/store-recovery.yml b/.github/workflows/store-recovery.yml new file mode 100644 index 0000000..7e98c5a --- /dev/null +++ b/.github/workflows/store-recovery.yml @@ -0,0 +1,68 @@ +name: Resume the published Store release + +on: + push: + tags: + - v-store-recovery-0.1.05-* + +permissions: + contents: read + actions: read + +jobs: + resume: + name: Resume the exact existing 0.1.05 Store draft + if: vars.OPENSTUDIO_STORE_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 25 + environment: microsoft-store + concurrency: + group: openstudio-microsoft-store-submission + cancel-in-progress: false + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Require a recovery tag for the immutable published release + shell: bash + run: | + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v-store-recovery-0\.1\.05-[1-9][0-9]*$ ]]; then + echo "This recovery is limited to the published 0.1.05 Store package." >&2 + exit 1 + fi + + - name: Retrieve the qualified package from the original release run + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: microsoft-store-package + path: dist/store + run-id: 37790026737 + github-token: ${{ github.token }} + + - name: Check the existing draft and exact release package without mutations + env: + GITHUB_TOKEN: ${{ github.token }} + MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} + MS_STORE_CLIENT_ID: ${{ secrets.MS_STORE_CLIENT_ID }} + MS_STORE_CLIENT_SECRET: ${{ secrets.MS_STORE_CLIENT_SECRET }} + run: python3 tools/resume_store_release.py --package-dir dist/store --report output/store-recovery-preflight.json --preflight + + - name: Resume upload and commit of the verified existing draft + env: + GITHUB_TOKEN: ${{ github.token }} + MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} + MS_STORE_CLIENT_ID: ${{ secrets.MS_STORE_CLIENT_ID }} + MS_STORE_CLIENT_SECRET: ${{ secrets.MS_STORE_CLIENT_SECRET }} + run: python3 tools/resume_store_release.py --package-dir dist/store --report output/store-recovery-submission.json --submit + + - name: Retain sanitized recovery status + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: microsoft-store-recovery-${{ github.run_attempt }} + path: | + output/store-recovery-preflight.json + output/store-recovery-submission.json + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index d82de50..31fb09c 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -44,6 +44,8 @@ jobs: tests.test_stem_model_downloads tests.test_updater_manifest tests.test_store_submission + tests.test_store_diagnosis + tests.test_store_recovery tests.test_store_release_workflow tests.test_ai_runtime_dependency_pins tests.test_release_ai_runtime @@ -53,6 +55,10 @@ jobs: tests.test_install_ai_tools_runtime_repair tests.test_prepare_diffusers_audio tests.test_diffusers_memory_policy + tests.test_reverb_qa_tools + tests.test_nam_fuzz_refinement + tests.test_desktop_qualification + tests.test_free_suite_ui_regression verify-ai-policy: name: AI execution policy (${{ matrix.os }}) @@ -138,6 +144,9 @@ jobs: - name: Run frontend unit tests run: npm test + - name: Typecheck interaction browser regressions + run: npm run test:e2e:types + - name: Install browsers on Linux if: runner.os == 'Linux' run: npx playwright install --with-deps chromium webkit @@ -217,6 +226,10 @@ jobs: shell: pwsh run: python tests/test_windows_installer.py --require-tools + - name: Test Windows startup qualification and process cleanup fixtures + shell: pwsh + run: python -B -m unittest tests.test_desktop_qualification tests.test_free_suite_ui_regression -v + - name: Test Windows signing safeguards shell: pwsh run: ./tools/test-windows-signing.ps1 @@ -303,6 +316,36 @@ jobs: shell: pwsh run: cmake --build $env:BUILD_DIR --config Release --target OpenStudio + - name: Install Chromium for native-schema editor qualification + shell: pwsh + working-directory: frontend + run: npx playwright install chromium + + - name: Verify project and preset file safety (Windows Release) + shell: pwsh + timeout-minutes: 3 + run: ./tools/run-runtime-safety-regression.ps1 -AppPath "$env:BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.exe" -Configuration Release -SkipBuild -SkipCrashTests + + - name: Qualify free FX editors with the current native schemas + shell: pwsh + timeout-minutes: 15 + run: | + $exe = Join-Path $env:GITHUB_WORKSPACE "$env:BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.exe" + ./tools/run-free-suite-ui-regression.ps1 -AppPath $exe + + - name: Retain free FX editor qualification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: free-suite-ui-windows-${{ github.run_attempt }} + path: | + output/review/free-suite-ui/editor-schemas.json + output/playwright/test-results + output/review/runtime-Release-*/contracts/result.json + output/review/runtime-Release-*/contracts/*.log + output/review/runtime-Release-*/qualification.json + if-no-files-found: warn + - name: Validate Windows runtime bundle shell: pwsh run: ./tools/validate-runtime-bundle.ps1 -Platform windows -BundlePath "$env:BUILD_DIR/OpenStudio_artefacts/Release" -ExpectedVersion $env:VERSION -EnforceLeanBundle @@ -346,6 +389,36 @@ jobs: $report = Join-Path $env:RUNNER_TEMP "OpenStudio_FXStageAutomationRegression.json" ./tools/run-free-plugin-headless-regression.ps1 -AppPath $exe -ReportPath $report -CaseName stage-automation + - name: Verify offline realtime track safety contracts (Windows Release) + shell: pwsh + timeout-minutes: 3 + run: | + Import-Module ./tools/OwnedDesktopProcess.psm1 -Force + $exe = Join-Path $env:GITHUB_WORKSPACE "$env:BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.exe" + $report = Join-Path $env:RUNNER_TEMP "OpenStudio_RealtimeTrackRegression.json" + $process = Start-OwnedDesktopProcess -FilePath $exe -ArgumentList @('--realtime-track-self-test', $report) + try { + if (!$process.WaitForExit(120000)) { throw 'Realtime track safety regression timed out.' } + if ($process.ExitCode -ne 0) { throw 'Realtime track safety regression failed.' } + } finally { $process.Dispose() } + $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json + if ($result.pass -ne $true) { throw 'Realtime track report did not pass.' } + foreach ($section in @('executor', 'eligibility', 'engine')) { + if ($result.$section.pass -ne $true) { throw "Missing or failed $section contracts." } + } + foreach ($section in @('executor', 'engine')) { + if ($null -eq $result.$section.checks -or $result.$section.checks.Count -eq 0) { throw "Empty $section contracts." } + } + if ($result.eligibility.parityBlocks -le 0) { throw 'No track eligibility parity blocks were checked.' } + + - name: Retain realtime track safety evidence (Windows) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: realtime-tracks-windows-${{ github.run_attempt }} + path: ${{ runner.temp }}/OpenStudio_RealtimeTrackRegression.json + if-no-files-found: warn + - name: Retain native FX order regression evidence (Windows) if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -354,6 +427,28 @@ jobs: path: ${{ runner.temp }}/OpenStudio_FXStageAutomationRegression.json if-no-files-found: warn + - name: Verify signal repairs and Reverb scheduling (Windows Release) + shell: pwsh + timeout-minutes: 10 + run: | + $exe = Join-Path $env:GITHUB_WORKSPACE "$env:BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.exe" + foreach ($case in @('signal-repairs', 'reverb-shimmer-scheduling', 'reverb-voice-options', 'dynamics-audit')) { + $report = Join-Path $env:RUNNER_TEMP "OpenStudio_$case.json" + ./tools/run-free-plugin-headless-regression.ps1 -AppPath $exe -ReportPath $report -CaseName $case + } + + - name: Retain native signal and scheduling evidence (Windows) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: signal-scheduling-windows-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/OpenStudio_signal-repairs.json + ${{ runner.temp }}/OpenStudio_reverb-shimmer-scheduling.json + ${{ runner.temp }}/OpenStudio_reverb-voice-options.json + ${{ runner.temp }}/OpenStudio_dynamics-audit.json + if-no-files-found: warn + - name: Run render and export regression matrix shell: pwsh run: | @@ -420,18 +515,26 @@ jobs: 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)) { + $userDataPath = [System.IO.Path]::GetFullPath($userDataMatch.Groups[1].Value.Trim()).TrimEnd('\') + $protectedRoot = [System.IO.Path]::GetFullPath($protectedBundle).TrimEnd('\') + if ($userDataPath.Equals($protectedRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + $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 + - name: Qualify protected Windows WebView startup in normal and safe modes 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 + timeout-minutes: 15 + run: | + $exePath = Join-Path $env:RUNNER_TEMP "OpenStudio-Protected-Install/OpenStudio.exe" + foreach ($mode in @('normal', 'safe')) { + foreach ($stale in @($false, $true)) { + $variant = if ($stale) { 'stale-runtime' } else { 'clean' } + $report = Join-Path $env:RUNNER_TEMP "OpenStudio_WindowLifecycleHarness-$mode-$variant.json" + ./tools/run-window-lifecycle-smoke.ps1 -AppPath $exePath -ReportPath $report ` + -StartupMode $mode -StaleRuntimeOverride:$stale -TimeoutSeconds 180 + } + } - name: Upload Windows native-window lifecycle report if: always() @@ -439,8 +542,8 @@ jobs: with: name: window-lifecycle-windows-${{ github.run_attempt }} path: | - ${{ runner.temp }}/OpenStudio_WindowLifecycleHarness.json - ${{ runner.temp }}/OpenStudio_WindowLifecycleHarness.startup.log + ${{ runner.temp }}/OpenStudio_WindowLifecycleHarness*.json + ${{ runner.temp }}/OpenStudio_WindowLifecycleHarness*.startup.log if-no-files-found: warn - name: Run NAM Rack deterministic regression @@ -467,6 +570,14 @@ jobs: steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Prepare system FFmpeg for audio conversion qualification + shell: bash + run: | + if ! command -v ffmpeg >/dev/null 2>&1; then + brew install ffmpeg + fi + ffmpeg -version + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version-file: frontend/.nvmrc @@ -541,6 +652,35 @@ jobs: "$APP_BUNDLE/Contents/MacOS/OpenStudio" --free-plugin-regression-headless --report "$REPORT" --case stage-automation python3 -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); assert result["overallPass"] is True and result["selectedCase"] == "stage-automation" and result["checks"], result' "$REPORT" + - name: Verify offline realtime track safety contracts (macOS Release) + shell: bash + timeout-minutes: 3 + run: | + REPORT="$RUNNER_TEMP/OpenStudio_RealtimeTrackRegression.json" + "$APP_BUNDLE/Contents/MacOS/OpenStudio" --realtime-track-self-test "$REPORT" + python3 -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); assert result["pass"] is True and all(result[key]["pass"] is True for key in ("executor", "eligibility", "engine")) and all(result[key]["checks"] for key in ("executor", "engine")) and result["eligibility"]["parityBlocks"] > 0, result' "$REPORT" + + - name: Verify project and preset file safety (macOS Release) + shell: pwsh + timeout-minutes: 3 + run: ./tools/run-runtime-safety-regression.ps1 -AppPath "$env:APP_BUNDLE/Contents/MacOS/OpenStudio" -Configuration Release -SkipBuild -SkipCrashTests + + - name: Retain realtime track safety evidence (macOS) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: realtime-tracks-${{ matrix.os }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/OpenStudio_RealtimeTrackRegression.json + output/review/runtime-Release-*/contracts/result.json + output/review/runtime-Release-*/contracts/*.log + output/review/runtime-Release-*/qualification.json + if-no-files-found: warn + + - name: Test POSIX desktop process ownership + shell: bash + run: python3 -B -m unittest tests.test_desktop_qualification -v + - name: Retain native FX order regression evidence (macOS) if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -582,7 +722,14 @@ jobs: - name: Install system dependencies shell: bash + timeout-minutes: 15 run: | + # The hosted image prefers Azure's mirror, which has stalled package + # downloads in this job. Use its existing official HTTPS fallbacks. + if [ -f /etc/apt/apt-mirrors.txt ]; then + printf 'https://archive.ubuntu.com/ubuntu/\tpriority:1\nhttps://security.ubuntu.com/ubuntu/\tpriority:2\n' \ + | sudo tee /etc/apt/apt-mirrors.txt >/dev/null + fi bash ./tools/setup-linux-prereqs.sh sudo apt-get install -y xvfb xauth openbox @@ -655,6 +802,22 @@ jobs: xvfb-run -a "$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio" --free-plugin-regression-headless --report "$REPORT" --case stage-automation python3 -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); assert result["overallPass"] is True and result["selectedCase"] == "stage-automation" and result["checks"], result' "$REPORT" + - name: Verify offline realtime track safety contracts (Linux Release) + shell: bash + timeout-minutes: 3 + run: | + REPORT="$RUNNER_TEMP/OpenStudio_RealtimeTrackRegression.json" + xvfb-run -a "$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio" --realtime-track-self-test "$REPORT" + python3 -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); assert result["pass"] is True and all(result[key]["pass"] is True for key in ("executor", "eligibility", "engine")) and all(result[key]["checks"] for key in ("executor", "engine")) and result["eligibility"]["parityBlocks"] > 0, result' "$REPORT" + + - name: Retain realtime track safety evidence (Linux) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: realtime-tracks-linux-${{ github.run_attempt }} + path: ${{ runner.temp }}/OpenStudio_RealtimeTrackRegression.json + if-no-files-found: warn + - name: Retain native FX order regression evidence (Linux) if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/CMakeLists.txt b/CMakeLists.txt index 79e00f7..32a6b1a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -252,6 +252,27 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND TARGET ysfx-private) TARGET_DIRECTORY ysfx-private APPEND PROPERTY COMPILE_OPTIONS "-Wno-ignored-attributes") endif() +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND TARGET lice) + # SWELL checks opendir immediately after CreateDirectory, intentionally + # discarding the macro's boolean result. + set_property(SOURCE + "${ysfx_SOURCE_DIR}/thirdparty/WDL/source/WDL/swell/swell-miscdlg-generic.cpp" + TARGET_DIRECTORY lice + APPEND PROPERTY COMPILE_OPTIONS "-Wno-unused-value") + if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15) + # At the pinned YSFX revision, PNG tRNS rejects alpha/even channel + # counts before writing tc[3]. GCC 15 loses that bound after inlining. + set_property(SOURCE "${ysfx_SOURCE_DIR}/sources/lice_stb/lice_stb_png.cpp" + TARGET_DIRECTORY lice + APPEND PROPERTY COMPILE_OPTIONS "-Wno-stringop-overflow") + # The empty-token fallback cannot satisfy n[0] == '('; short-circuit + # evaluation protects n[1] in WDL_hasStringsEx. + set_property(SOURCE + "${ysfx_SOURCE_DIR}/thirdparty/WDL/source/WDL/swell/swell-miscdlg-generic.cpp" + TARGET_DIRECTORY lice + APPEND PROPERTY COMPILE_OPTIONS "-Wno-array-bounds") + endif() +endif() if(APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "^(AppleClang|Clang)$" AND TARGET lice) # Pinned WDL/SWELL supports legacy AppKit APIs, and stb's writer uses # sprintf internally. Limit deprecation exceptions to their vendor sources; @@ -493,6 +514,7 @@ target_sources(OpenStudio PRIVATE Source/RecordingRecovery.cpp Source/RecordingRecoveryRegression.cpp Source/AudioEngine.cpp + Source/RealtimeTrackExecutor.cpp Source/MetronomeRegression.cpp Source/FreePluginRegression.cpp Source/NAMDelayRegression.h @@ -588,6 +610,7 @@ endif() # creates periodic work spikes that do not represent the shipped DSP. set(OPENSTUDIO_REALTIME_AUDIO_SOURCES Source/AudioEngine.cpp + Source/RealtimeTrackExecutor.cpp Source/AudioRecorder.cpp Source/PlaybackEngine.cpp Source/TrackProcessor.cpp @@ -601,6 +624,8 @@ set(OPENSTUDIO_REALTIME_AUDIO_SOURCES Source/NAMCabPresentation.cpp Source/NAMPolyOctaver.cpp Source/OpenStudioPitchCorrector.cpp + Source/PitchDetector.cpp + Source/PitchMapper.cpp Source/TunerPitchTracker.cpp "${juce_SOURCE_DIR}/modules/juce_audio_basics/juce_audio_basics.cpp" "${juce_SOURCE_DIR}/modules/juce_audio_devices/juce_audio_devices.cpp" diff --git a/Source/ARAHostController.cpp b/Source/ARAHostController.cpp index f632c25..eeacef1 100644 --- a/Source/ARAHostController.cpp +++ b/Source/ARAHostController.cpp @@ -242,7 +242,9 @@ ARAHostController::OpenStudioContentAccessController::createMusicalContextConten ARA::ARAMusicalContextHostRef, ARA::ARAContentType type, const ARA::ARAContentTimeRange*) noexcept { - return Converter::toHostRef (type); + if (!isMusicalContextContentAvailable(nullptr, type)) + return nullptr; + return Converter::toHostRef (static_cast(type)); } bool ARAHostController::OpenStudioContentAccessController::isAudioSourceContentAvailable ( @@ -278,6 +280,8 @@ const void* ARAHostController::OpenStudioContentAccessController::getContentRead ARA::ARAContentReaderHostRef contentReaderHostRef, ARA::ARAInt32 eventIndex) noexcept { + if (eventIndex < 0 || eventIndex >= getContentReaderEventCount(contentReaderHostRef)) + return nullptr; if (Converter::fromHostRef (contentReaderHostRef) == ARA::kARAContentTypeTempoEntries) { if (eventIndex == 0) diff --git a/Source/ARAHostController.h b/Source/ARAHostController.h index 74281ae..4942795 100644 --- a/Source/ARAHostController.h +++ b/Source/ARAHostController.h @@ -11,6 +11,7 @@ #include #include "ARADebug.h" +#include #if OpenStudio_HAS_ARA #include @@ -119,6 +120,7 @@ class ARAHostController private: + friend class RuntimeSafetyRegression; bool araActive = false; juce::AudioPluginInstance* araProcessor = nullptr; double currentSampleRate = 44100.0; @@ -177,7 +179,10 @@ class ARAHostController class OpenStudioContentAccessController final : public ARA::Host::ContentAccessControllerInterface { public: - using Converter = ARAHostModel::ConversionFunctions; + // JUCE copies sizeof(HostRef) bytes; the SDK content enum is only + // 32 bits, so widen it before converting to an opaque reader handle. + using Converter = ARAHostModel::ConversionFunctions; + static_assert(sizeof(std::uintptr_t) == sizeof(ARA::ARAContentReaderHostRef)); bool isMusicalContextContentAvailable (ARA::ARAMusicalContextHostRef, ARA::ARAContentType type) noexcept override; ARA::ARAContentGrade getMusicalContextContentGrade (ARA::ARAMusicalContextHostRef, ARA::ARAContentType) noexcept override; diff --git a/Source/ASIOCapabilities.h b/Source/ASIOCapabilities.h index 81a009f..753e25c 100644 --- a/Source/ASIOCapabilities.h +++ b/Source/ASIOCapabilities.h @@ -1,5 +1,6 @@ #pragma once #include +#include #if JUCE_WINDOWS && JUCE_ASIO #ifndef NOMINMAX @@ -58,16 +59,21 @@ inline bool query(const juce::String& name, juce::DynamicObject& result, if (preferred > 0 && preferred >= minimum && preferred <= maximum && !buffers.contains(juce::var(static_cast(preferred)))) buffers.add(static_cast(preferred)); double currentRate = 0; - driver->getSampleRate(¤tRate); + const bool currentRateRead = driver->getSampleRate(¤tRate) == ASE_OK + && std::isfinite(currentRate) && currentRate > 0.0; juce::Array rates; for (const double rate : { 8000., 11025., 16000., 22050., 32000., 44100., 48000., 64000., 88200., 96000., 176400., 192000., 352800., 384000., 705600., 768000. }) if (driver->canSampleRate(rate) == ASE_OK) rates.add(rate); - if (currentRate > 0 && !rates.contains(juce::var(currentRate))) rates.add(currentRate); + if (currentRateRead && !rates.contains(juce::var(currentRate))) rates.add(currentRate); if (rates.isEmpty() || buffers.isEmpty()) return false; result.setProperty("sampleRates", rates); result.setProperty("bufferSizes", buffers); - result.setProperty("defaultSampleRate", currentRate > 0 ? currentRate : static_cast(rates[0])); + result.setProperty("defaultSampleRate", currentRateRead ? currentRate : static_cast(rates[0])); result.setProperty("defaultBufferSize", static_cast(preferred > 0 ? preferred : minimum)); + // Qualification/restoration must distinguish a driver readback from defaults. + result.setProperty("currentSampleRateReadbackValid", currentRateRead); + result.setProperty("reportedCurrentSampleRate", currentRateRead ? currentRate : 0.0); + result.setProperty("reportedPreferredBufferSize", static_cast(preferred)); long inputs = 0, outputs = 0; if (driver->getChannels(&inputs, &outputs) == ASE_OK) { diff --git a/Source/AppUpdater.cpp b/Source/AppUpdater.cpp index c43bede..6971861 100644 --- a/Source/AppUpdater.cpp +++ b/Source/AppUpdater.cpp @@ -49,6 +49,14 @@ AppUpdater::AppUpdater(const juce::File& directory, bool restoreOnStartup) : sta rejectUnsupportedInstallerUpdate({}); return; // Never restore or download a staged AppImage for a native package. } + // This member is constructed before startup logging creates the shared + // profile directory. Create a new profile privately, without changing an + // existing profile's permissions or metadata compatibility. + if (!stateDirectory.exists() && !UpdateInstaller::ensurePrivateDirectory(stateDirectory)) + { + publishStatus(makeStatus("error", "Could not create a secure updater state folder.")); + return; + } const auto stateFile = stateDirectory.getChildFile("updater-state.json"); if (stateFile.existsAsFile()) { @@ -458,8 +466,8 @@ juce::var AppUpdater::performDownload(const juce::var& offer) const juce::URL url(downloadUrl); const auto installerFile = stateDirectory.getChildFile("updates") .getChildFile(juce::Uuid().toString()).getChildFile(getDownloadFileName(url, version)); - if (installerFile.getParentDirectory().createDirectory().failed()) - return finish("error", "Could not create the update download folder."); + if (!UpdateInstaller::ensurePrivateDirectory(installerFile.getParentDirectory())) + return finish("error", "Could not create a secure update download folder."); auto progress = [this, alive = jobs.token(), offer, size](juce::int64 bytes) { auto status = offer.clone(); @@ -649,7 +657,8 @@ void AppUpdater::recordSuccessfulCheck(const juce::String& latestVersion, const bool AppUpdater::savePersistedState() const { const auto stateFile = stateDirectory.getChildFile("updater-state.json"); - stateFile.getParentDirectory().createDirectory(); + if (!stateDirectory.exists() && !UpdateInstaller::ensurePrivateDirectory(stateDirectory)) + return false; return stateFile.replaceWithText(juce::JSON::toString(persistedState)); } diff --git a/Source/AudioDeviceConfiguration.h b/Source/AudioDeviceConfiguration.h index 40e29e9..2e3b642 100644 --- a/Source/AudioDeviceConfiguration.h +++ b/Source/AudioDeviceConfiguration.h @@ -5,6 +5,72 @@ namespace AudioDeviceConfiguration { +inline juce::String resolveDeviceName(const juce::var& request, + const juce::Identifier& key, + const juce::StringArray& available, + int defaultIndex, + bool useDefaultDevices) +{ + const auto requested = request.getProperty(key, {}).toString(); + if (!useDefaultDevices && request.hasProperty(key) && requested.isEmpty()) + return {}; + if (available.contains(requested)) + return requested; + return available.isEmpty() ? juce::String() + : available[juce::jlimit(0, available.size() - 1, defaultIndex)]; +} + +struct CoupledDeviceNames +{ + juce::String input; + juce::String output; +}; + +// A scalar settings edit must retain an explicitly disabled side. Missing names +// and a new audio-system selection still use the driver's coupled defaults. +inline CoupledDeviceNames resolveCoupledDeviceNames(const juce::var& request, + const juce::String& resolvedInput, + const juce::String& resolvedOutput, + bool useDefaultDevices) +{ + const auto requestedInput = request.getProperty("inputDevice", {}).toString(); + const auto requestedOutput = request.getProperty("outputDevice", {}).toString(); + if (!useDefaultDevices && request.hasProperty("inputDevice") && requestedInput.isEmpty() + && requestedOutput.isNotEmpty() && requestedOutput == resolvedOutput) + return { {}, resolvedOutput }; + if (!useDefaultDevices && request.hasProperty("outputDevice") && requestedOutput.isEmpty() + && requestedInput.isNotEmpty() && requestedInput == resolvedInput) + return { resolvedInput, {} }; + + const auto selected = resolvedOutput.isNotEmpty() ? resolvedOutput : resolvedInput; + return { selected, selected }; +} + +// ASIO exposes one coupled device even when only its input or output is active. +// Treat an inactive side as absent, rather than probing the streaming driver a +// second time just because its saved input/output names differ from the draft. +inline bool isSameStreamingDevice(const juce::String& requestedType, + const juce::String& requestedInput, + const juce::String& requestedOutput, + const juce::String& activeType, + const juce::String& activeInput, + const juce::String& activeOutput, + bool separateInputsAndOutputs) +{ + if (requestedType != activeType) + return false; + if (separateInputsAndOutputs) + return requestedInput == activeInput && requestedOutput == activeOutput; + + const auto selected = requestedOutput.isNotEmpty() ? requestedOutput : requestedInput; + return selected.isNotEmpty() + && (requestedInput.isEmpty() || requestedInput == selected) + && (requestedOutput.isEmpty() || requestedOutput == selected) + && (activeInput.isNotEmpty() || activeOutput.isNotEmpty()) + && (activeInput.isEmpty() || activeInput == selected) + && (activeOutput.isEmpty() || activeOutput == selected); +} + // Message-thread transaction. Explicit empty names mean disabled; only a type // switch requesting defaults may substitute the driver's default devices. template @@ -13,8 +79,13 @@ juce::String apply(juce::AudioDeviceManager& manager, juce::AudioDeviceManager::AudioDeviceSetup setup, bool useDefaultDevices, bool inputAllowed, - ConfigureChannels configureChannels) + ConfigureChannels configureChannels, + bool verifySampleRate = true, + bool verifyBufferSize = true, + juce::AudioDeviceManager::AudioDeviceSetup* acceptedSetup = nullptr) { + if (acceptedSetup != nullptr) + *acceptedSetup = {}; if (!std::isfinite(setup.sampleRate) || setup.sampleRate <= 0.0) return "The requested audio sample rate must be positive and finite."; if (setup.bufferSize <= 0) @@ -75,6 +146,25 @@ juce::String apply(juce::AudioDeviceManager& manager, auto error = manager.initialise(setup.inputChannels.countNumberOfSetBits(), setup.outputChannels.countNumberOfSetBits(), &requested, false); + if (auto* device = manager.getCurrentAudioDevice()) + { + auto accepted = manager.getAudioDeviceSetup(); + accepted.sampleRate = device->getCurrentSampleRate(); + accepted.bufferSize = device->getCurrentBufferSizeSamples(); + if (acceptedSetup != nullptr) + *acceptedSetup = accepted; + if (error.isEmpty() + && ((verifySampleRate && (!std::isfinite(accepted.sampleRate) + || std::abs(accepted.sampleRate - setup.sampleRate) > 1.0)) + || (verifyBufferSize && accepted.bufferSize != setup.bufferSize))) + { + error = "The audio driver did not accept the requested settings. Requested " + + juce::String(setup.sampleRate, 0) + " Hz / " + juce::String(setup.bufferSize) + + " samples; opened " + juce::String(accepted.sampleRate, 0) + " Hz / " + + juce::String(accepted.bufferSize) + + " samples. Choose supported settings or change them in the driver's control panel."; + } + } if (error.isEmpty() && (setup.inputDeviceName.isNotEmpty() || setup.outputDeviceName.isNotEmpty()) && manager.getCurrentAudioDevice() == nullptr) @@ -82,9 +172,23 @@ juce::String apply(juce::AudioDeviceManager& manager, if (error.isNotEmpty()) { manager.closeAudioDevice(); - const auto restoreError = manager.initialise(oldSetup.inputChannels.countNumberOfSetBits(), - oldSetup.outputChannels.countNumberOfSetBits(), - previous.get(), false); + auto restoreError = manager.initialise(oldSetup.inputChannels.countNumberOfSetBits(), + oldSetup.outputChannels.countNumberOfSetBits(), + previous.get(), false); + if (restoreError.isEmpty() + && (oldSetup.inputDeviceName.isNotEmpty() || oldSetup.outputDeviceName.isNotEmpty())) + { + auto* restored = manager.getCurrentAudioDevice(); + if (restored == nullptr) + restoreError = "The previous device did not reopen."; + else if (!std::isfinite(restored->getCurrentSampleRate()) + || std::abs(restored->getCurrentSampleRate() - oldSetup.sampleRate) > 1.0 + || restored->getCurrentBufferSizeSamples() != oldSetup.bufferSize) + restoreError = "The driver reopened at " + juce::String(restored->getCurrentSampleRate(), 0) + + " Hz / " + juce::String(restored->getCurrentBufferSizeSamples()) + + " samples instead of the previous " + juce::String(oldSetup.sampleRate, 0) + + " Hz / " + juce::String(oldSetup.bufferSize) + " samples."; + } if (restoreError.isNotEmpty()) error += " Previous audio settings could not be restored: " + restoreError; } diff --git a/Source/AudioDeviceConfigurationRegression.h b/Source/AudioDeviceConfigurationRegression.h index 117a3d4..905b586 100644 --- a/Source/AudioDeviceConfigurationRegression.h +++ b/Source/AudioDeviceConfigurationRegression.h @@ -1,21 +1,33 @@ #pragma once #include "AudioDeviceConfiguration.h" +#include +#include +#include namespace AudioDeviceConfigurationRegression { +enum class OpenMode { normal, busy, coerceBuffer, coerceRate, coerceRollback }; + class Device final : public juce::AudioIODevice { public: - Device(const juce::String& type, bool fail) : AudioIODevice("Interface", type), failOpen(fail) {} + Device(const juce::String& type, const juce::String& name, std::shared_ptr mode) + : AudioIODevice(name, type), openMode(std::move(mode)) {} juce::StringArray getOutputChannelNames() override { return {"Out 1", "Out 2"}; } juce::StringArray getInputChannelNames() override { return {"In 1", "In 2"}; } juce::Array getAvailableSampleRates() override { return {44100.0, 48000.0}; } - juce::Array getAvailableBufferSizes() override { return {256, 512}; } + juce::Array getAvailableBufferSizes() override { return {8, 16, 32, 64, 128, 256, 512}; } int getDefaultBufferSize() override { return 512; } juce::String open(const juce::BigInteger& in, const juce::BigInteger& out, double sr, int bs) override { - if (failOpen) return "Fixture device busy"; - inputs = in; outputs = out; rate = sr; block = bs; opened = true; return {}; + if (*openMode == OpenMode::busy) return "Fixture device busy"; + inputs = in; + outputs = out; + rate = *openMode == OpenMode::coerceRate ? 44100.0 : sr; + block = (*openMode == OpenMode::coerceBuffer || *openMode == OpenMode::coerceRollback) && bs == 16 ? 8 : bs; + if (*openMode == OpenMode::coerceRollback && bs == 512) block = 256; + opened = true; + return {}; } void close() override { opened = false; } bool isOpen() override { return opened; } @@ -33,7 +45,8 @@ class Device final : public juce::AudioIODevice int getOutputLatencyInSamples() override { return 0; } int getInputLatencyInSamples() override { return 0; } private: - bool failOpen = false, opened = false; + std::shared_ptr openMode; + bool opened = false; double rate = 48000.0; int block = 512; juce::BigInteger inputs, outputs; @@ -43,18 +56,24 @@ class Device final : public juce::AudioIODevice class Type final : public juce::AudioIODeviceType { public: - Type(const juce::String& name, bool empty = false, bool fail = false) - : AudioIODeviceType(name), emptyDevices(empty), failOpen(fail) {} + Type(const juce::String& name, bool empty = false, OpenMode mode = OpenMode::normal, + bool separate = true, juce::StringArray names = { "Interface" }) + : AudioIODeviceType(name), emptyDevices(empty), separateDevices(separate), + deviceNames(std::move(names)), openMode(std::make_shared(mode)) {} + void setOpenMode(OpenMode mode) { *openMode = mode; } void scanForDevices() override {} juce::StringArray getDeviceNames(bool) const override - { return emptyDevices ? juce::StringArray() : juce::StringArray{"Interface"}; } + { return emptyDevices ? juce::StringArray() : deviceNames; } int getDefaultDeviceIndex(bool) const override { return emptyDevices ? -1 : 0; } - int getIndexOfDevice(juce::AudioIODevice*, bool) const override { return 0; } - bool hasSeparateInputsAndOutputs() const override { return true; } - juce::AudioIODevice* createDevice(const juce::String&, const juce::String&) override - { return new Device(getTypeName(), failOpen); } + int getIndexOfDevice(juce::AudioIODevice* device, bool) const override + { return device == nullptr ? -1 : deviceNames.indexOf(device->getName()); } + bool hasSeparateInputsAndOutputs() const override { return separateDevices; } + juce::AudioIODevice* createDevice(const juce::String& output, const juce::String& input) override + { return new Device(getTypeName(), output.isNotEmpty() ? output : input, openMode); } private: - bool emptyDevices, failOpen; + bool emptyDevices, separateDevices; + juce::StringArray deviceNames; + std::shared_ptr openMode; }; template @@ -68,30 +87,91 @@ void run(Check check) manager.addAudioDeviceType(std::make_unique("Working")); manager.addAudioDeviceType(std::make_unique("Other")); manager.addAudioDeviceType(std::make_unique("JACK", true)); - manager.addAudioDeviceType(std::make_unique("Busy", false, true)); + manager.addAudioDeviceType(std::make_unique("Busy", false, OpenMode::busy)); + manager.addAudioDeviceType(std::make_unique("CoerceBuffer", false, OpenMode::coerceBuffer)); + manager.addAudioDeviceType(std::make_unique("CoerceRate", false, OpenMode::coerceRate)); + manager.addAudioDeviceType(std::make_unique("Coupled", false, OpenMode::normal, false, + juce::StringArray { "Interface", "Other Interface" })); + auto rollbackType = std::make_unique("RollbackCoercion"); + auto* const rollbackControl = rollbackType.get(); + manager.addAudioDeviceType(std::move(rollbackType)); juce::AudioDeviceManager::AudioDeviceSetup request; request.sampleRate = 48000.0; request.bufferSize = 512; - const auto apply = [&] (const juce::String& type, bool defaults, bool inputAllowed = true) + const auto apply = [&] (const juce::String& type, bool defaults, bool inputAllowed = true, + bool verifyRate = true, bool verifyBuffer = true, + juce::AudioDeviceManager::AudioDeviceSetup* accepted = nullptr) { return AudioDeviceConfiguration::apply(manager, type, request, defaults, inputAllowed, [] (auto& setup) { setup.inputChannels.clear(); setup.outputChannels.clear(); if (setup.inputDeviceName.isNotEmpty()) setup.inputChannels.setRange(0, 2, true); if (setup.outputDeviceName.isNotEmpty()) setup.outputChannels.setRange(0, 2, true); setup.useDefaultInputChannels = setup.useDefaultOutputChannels = false; - }); + }, verifyRate, verifyBuffer, accepted); }; check("audio_switch_defaults_open_duplex", apply("Working", true).isEmpty() && manager.getCurrentAudioDevice() != nullptr && manager.getAudioDeviceSetup().inputChannels.countNumberOfSetBits() == 2); check("audio_switch_other_type_opens_defaults", apply("Other", true).isEmpty() && manager.getCurrentAudioDeviceType() == "Other" && manager.getCurrentAudioDevice() != nullptr); + auto* const deviceBeforeBufferChange = manager.getCurrentAudioDevice(); + request.bufferSize = 16; + check("audio_in_place_16_sample_request_is_accepted", apply("Other", true).isEmpty() + && manager.getCurrentAudioDevice() == deviceBeforeBufferChange + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 16); + request.bufferSize = 512; + check("audio_in_place_buffer_restore_is_accepted", apply("Other", true).isEmpty() + && manager.getAudioDeviceSetup().bufferSize == 512); const auto previous = manager.createStateXml()->toString(); check("audio_switch_empty_jack_preserves_working_device", apply("JACK", true).contains("JACK") && manager.getCurrentAudioDeviceType() == "Other" && manager.getCurrentAudioDevice() != nullptr); check("audio_switch_open_failure_rolls_back", apply("Busy", true).contains("Fixture device busy") && manager.getCurrentAudioDeviceType() == "Other" && manager.getCurrentAudioDevice() != nullptr && manager.createStateXml()->toString() == previous); + juce::AudioDeviceManager::AudioDeviceSetup accepted; + request.bufferSize = 16; + const auto coercedBufferError = apply("CoerceBuffer", true, true, true, true, &accepted); + check("audio_successful_buffer_coercion_is_rejected_and_rolled_back", + coercedBufferError.contains("16 samples") && coercedBufferError.contains("8 samples") + && accepted.bufferSize == 8 + && manager.getCurrentAudioDeviceType() == "Other" + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 512 + && manager.createStateXml()->toString() == previous); + request.bufferSize = 512; + const auto coercedRateError = apply("CoerceRate", true, true, true, true, &accepted); + check("audio_successful_rate_coercion_is_rejected_and_rolled_back", + coercedRateError.contains("48000 Hz") && coercedRateError.contains("44100 Hz") + && accepted.sampleRate == 44100.0 + && manager.getCurrentAudioDeviceType() == "Other" + && manager.createStateXml()->toString() == previous); + check("audio_automatic_rate_accepts_driver_fallback", apply("CoerceRate", true, true, false).isEmpty() + && manager.getCurrentAudioDevice()->getCurrentSampleRate() == 44100.0); + request.bufferSize = 16; + check("audio_automatic_buffer_accepts_driver_fallback", apply("CoerceBuffer", true, true, true, false).isEmpty() + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 8); + request.bufferSize = 512; + check("audio_coercing_device_accepts_baseline", apply("CoerceBuffer", true).isEmpty()); + const auto coercingBaseline = manager.createStateXml()->toString(); + request.bufferSize = 16; + check("audio_in_place_successful_coercion_restores_same_driver", + apply("CoerceBuffer", true).contains("did not accept") + && manager.getCurrentAudioDeviceType() == "CoerceBuffer" + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 512 + && manager.createStateXml()->toString() == coercingBaseline); + request.bufferSize = 512; + check("audio_restore_after_automatic_fallback", apply("Other", true).isEmpty()); + check("audio_rollback_coercion_fixture_opens_baseline", apply("RollbackCoercion", true).isEmpty()); + rollbackControl->setOpenMode(OpenMode::coerceRollback); + request.bufferSize = 16; + const auto rollbackCoercionError = apply("RollbackCoercion", true); + check("audio_silent_rollback_coercion_is_reported_honestly", + rollbackCoercionError.contains("Previous audio settings could not be restored") + && rollbackCoercionError.contains("256 samples") + && manager.getCurrentAudioDevice() != nullptr + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 256); + rollbackControl->setOpenMode(OpenMode::normal); + request.bufferSize = 512; + check("audio_restore_after_rollback_coercion", apply("Other", true).isEmpty()); request.bufferSize = 0; check("audio_invalid_buffer_does_not_switch", apply("Working", true).isNotEmpty() && manager.getCurrentAudioDeviceType() == "Other"); @@ -121,5 +201,122 @@ void run(Check check) request.outputDeviceName.clear(); check("audio_explicit_disable_both_closes_device", apply("Working", false).isEmpty() && manager.getCurrentAudioDevice() == nullptr); + + const auto coupledRequest = [] (const juce::String& input, const juce::String& output) + { + auto* values = new juce::DynamicObject(); + values->setProperty("inputDevice", input); + values->setProperty("outputDevice", output); + return juce::var(values); + }; + request.bufferSize = 8; + request.outputDeviceName = "Interface"; + check("audio_coupled_output_only_fixture_opens", apply("Coupled", false).isEmpty() + && manager.getCurrentAudioDevice()->getActiveInputChannels().isZero()); + auto names = AudioDeviceConfiguration::resolveCoupledDeviceNames( + coupledRequest({}, "Interface"), "Interface", "Interface", false); + request.inputDeviceName = names.input; + request.outputDeviceName = names.output; + request.bufferSize = 16; + check("audio_coupled_output_only_buffer_edit_preserves_disabled_input", + apply("Coupled", false).isEmpty() + && manager.getAudioDeviceSetup().inputDeviceName.isEmpty() + && manager.getCurrentAudioDevice()->getActiveInputChannels().isZero() + && manager.getCurrentAudioDevice()->getActiveOutputChannels().countNumberOfSetBits() == 2 + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 16); + request.inputDeviceName = "Interface"; + request.outputDeviceName.clear(); + request.bufferSize = 8; + check("audio_coupled_input_only_fixture_opens", apply("Coupled", false).isEmpty() + && manager.getCurrentAudioDevice()->getActiveOutputChannels().isZero()); + names = AudioDeviceConfiguration::resolveCoupledDeviceNames( + coupledRequest("Interface", {}), "Interface", "Interface", false); + request.inputDeviceName = names.input; + request.outputDeviceName = names.output; + request.bufferSize = 16; + check("audio_coupled_input_only_buffer_edit_preserves_disabled_output", + apply("Coupled", false).isEmpty() + && manager.getAudioDeviceSetup().outputDeviceName.isEmpty() + && manager.getCurrentAudioDevice()->getActiveOutputChannels().isZero() + && manager.getCurrentAudioDevice()->getActiveInputChannels().countNumberOfSetBits() == 2 + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 16); + names = AudioDeviceConfiguration::resolveCoupledDeviceNames( + coupledRequest("Other Interface", "Other Interface"), "Other Interface", "Other Interface", false); + request.inputDeviceName = names.input; + request.outputDeviceName = names.output; + check("audio_coupled_driver_selection_enables_requested_duplex", + apply("Coupled", false).isEmpty() + && manager.getCurrentAudioDevice()->getName() == "Other Interface" + && manager.getCurrentAudioDevice()->getActiveInputChannels().countNumberOfSetBits() == 2 + && manager.getCurrentAudioDevice()->getActiveOutputChannels().countNumberOfSetBits() == 2); + names = AudioDeviceConfiguration::resolveCoupledDeviceNames( + coupledRequest({}, "Interface"), "Interface", "Interface", true); + check("audio_new_coupled_system_uses_duplex_defaults", + names.input == "Interface" && names.output == "Interface"); + auto omittedInput = coupledRequest({}, "Interface"); + omittedInput.getDynamicObject()->removeProperty("inputDevice"); + names = AudioDeviceConfiguration::resolveCoupledDeviceNames(omittedInput, "Interface", "Interface", false); + check("audio_omitted_coupled_input_uses_default_rather_than_disable", + names.input == "Interface" && names.output == "Interface"); + auto omittedOutput = coupledRequest("Interface", {}); + omittedOutput.getDynamicObject()->removeProperty("outputDevice"); + names = AudioDeviceConfiguration::resolveCoupledDeviceNames(omittedOutput, "Interface", "Interface", false); + check("audio_omitted_coupled_output_uses_default_rather_than_disable", + names.input == "Interface" && names.output == "Interface"); + + const juce::StringArray endpoints { "Interface" }; + const auto endpointSelection = coupledRequest({}, "Interface"); + request.inputDeviceName = AudioDeviceConfiguration::resolveDeviceName( + endpointSelection, "inputDevice", endpoints, 0, false); + request.outputDeviceName = AudioDeviceConfiguration::resolveDeviceName( + endpointSelection, "outputDevice", endpoints, 0, false); + request.bufferSize = 8; + check("audio_separate_output_only_fixture_opens", apply("Working", false).isEmpty() + && manager.getCurrentAudioDevice()->getActiveInputChannels().isZero()); + request.bufferSize = 16; + check("audio_separate_output_only_buffer_edit_preserves_disabled_input", + apply("Working", false).isEmpty() + && manager.getAudioDeviceSetup().inputDeviceName.isEmpty() + && manager.getCurrentAudioDevice()->getActiveInputChannels().isZero() + && manager.getCurrentAudioDevice()->getActiveOutputChannels().countNumberOfSetBits() == 2 + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 16); + check("audio_separate_omitted_input_resolves_default", + AudioDeviceConfiguration::resolveDeviceName(omittedInput, "inputDevice", endpoints, 0, false) == "Interface"); + check("audio_separate_new_system_resolves_defaults", + AudioDeviceConfiguration::resolveDeviceName(endpointSelection, "inputDevice", endpoints, 0, true) == "Interface"); + const auto inputOnlySelection = coupledRequest("Interface", {}); + request.inputDeviceName = AudioDeviceConfiguration::resolveDeviceName( + inputOnlySelection, "inputDevice", endpoints, 0, false); + request.outputDeviceName = AudioDeviceConfiguration::resolveDeviceName( + inputOnlySelection, "outputDevice", endpoints, 0, false); + request.bufferSize = 8; + check("audio_separate_input_only_fixture_opens", apply("Working", false).isEmpty() + && manager.getCurrentAudioDevice()->getActiveOutputChannels().isZero()); + request.bufferSize = 16; + check("audio_separate_input_only_buffer_edit_preserves_disabled_output", + apply("Working", false).isEmpty() + && manager.getAudioDeviceSetup().outputDeviceName.isEmpty() + && manager.getCurrentAudioDevice()->getActiveOutputChannels().isZero() + && manager.getCurrentAudioDevice()->getActiveInputChannels().countNumberOfSetBits() == 2 + && manager.getCurrentAudioDevice()->getCurrentBufferSizeSamples() == 16); + check("audio_separate_omitted_output_resolves_default", + AudioDeviceConfiguration::resolveDeviceName(omittedOutput, "outputDevice", endpoints, 0, false) == "Interface"); + + const auto sameASIO = [] (const juce::String& activeInput, const juce::String& activeOutput) + { + return AudioDeviceConfiguration::isSameStreamingDevice( + "ASIO", "Interface", "Interface", "ASIO", activeInput, activeOutput, false); + }; + check("audio_active_asio_duplex_uses_cached_capabilities", sameASIO("Interface", "Interface")); + check("audio_active_asio_output_only_uses_cached_capabilities", sameASIO({}, "Interface")); + check("audio_active_asio_input_only_uses_cached_capabilities", sameASIO("Interface", {})); + check("audio_inactive_asio_requires_capability_probe", !sameASIO({}, {})); + check("audio_different_asio_device_requires_capability_probe", !sameASIO("Other", "Other")); + check("audio_separate_endpoints_preserve_exact_identity", + !AudioDeviceConfiguration::isSameStreamingDevice( + "WASAPI", "Interface", "Interface", "WASAPI", {}, "Interface", true)); + check("audio_different_system_preserves_exact_identity", + !AudioDeviceConfiguration::isSameStreamingDevice( + "ASIO", "Interface", "Interface", "Other", "Interface", "Interface", false)); } } diff --git a/Source/AudioDeviceProbe.h b/Source/AudioDeviceProbe.h index 0f383dc..52738e5 100644 --- a/Source/AudioDeviceProbe.h +++ b/Source/AudioDeviceProbe.h @@ -1,12 +1,213 @@ #pragma once #include "AudioDeviceConfiguration.h" #include "AudioRecorder.h" +#include "ASIOCapabilities.h" +#include "AppPaths.h" #include +#include // Opt-in hardware qualification. Uses the same device transaction and recording // writer as the app, without loading/saving user settings or opening a project. namespace AudioDeviceProbe { +class SilentCallback final : public juce::AudioIODeviceCallback +{ +public: + void audioDeviceAboutToStart(juce::AudioIODevice*) override {} + void audioDeviceStopped() override {} + void audioDeviceIOCallbackWithContext(const float* const* inputs, int inputCount, + float* const* outputs, int outputCount, int samples, + const juce::AudioIODeviceCallbackContext&) override + { + // Physical inputs are deliberately never read, copied or recorded. + juce::ignoreUnused(inputs, inputCount); + for (int channel = 0; channel < outputCount; ++channel) + if (outputs[channel] != nullptr) + juce::FloatVectorOperations::clear(outputs[channel], samples); + frames.fetch_add(samples, std::memory_order_relaxed); + blocks.fetch_add(1, std::memory_order_relaxed); + int previousMinimum = minimumBlock.load(std::memory_order_relaxed); + while (samples < previousMinimum && ! minimumBlock.compare_exchange_weak( + previousMinimum, samples, std::memory_order_relaxed)) {} + int previousMaximum = maximumBlock.load(std::memory_order_relaxed); + while (samples > previousMaximum && ! maximumBlock.compare_exchange_weak( + previousMaximum, samples, std::memory_order_relaxed)) {} + } + void clearCounters() noexcept + { + frames.store(0, std::memory_order_relaxed); + blocks.store(0, std::memory_order_relaxed); + minimumBlock.store(std::numeric_limits::max(), std::memory_order_relaxed); + maximumBlock.store(0, std::memory_order_relaxed); + } + std::atomic frames {0}, blocks {0}; + std::atomic minimumBlock {std::numeric_limits::max()}, maximumBlock {0}; +}; + +// Explicit opt-in, silent device transaction test. Run with other audio clients +// closed: an inactive ASIO driver exposes its preferred buffer, not another +// application's active stream configuration. No settings file is loaded/saved. +inline int runSilentASIOBufferChange(const juce::String& nameFragment, + const juce::File& directory, + int durationMilliseconds = 750) +{ + if (nameFragment.isEmpty() || durationMilliseconds < 100 || durationMilliseconds > 1500 + || directory.exists() || ! directory.createDirectory()) + return 2; + juce::Array checks; + bool passed = true; + const auto report = [&] (const juce::String& id, bool success, const juce::var& detail) + { + auto* row = new juce::DynamicObject(); + row->setProperty("id", id); + row->setProperty("status", success ? "pass" : "fail"); + row->setProperty("detail", detail); + checks.add(row); + passed = passed && success; + }; + const auto settingsFile = AppPaths::applicationData().getChildFile("audio_device_settings.xml"); + const bool settingsExisted = settingsFile.exists(); + juce::MemoryBlock settingsBefore; + const bool settingsReadable = ! settingsExisted || settingsFile.loadFileAsData(settingsBefore); + report("saved_settings_snapshot", settingsReadable, settingsFile.getFullPathName()); + + auto manager = std::make_unique(); + juce::String selected; + juce::StringArray matches; + for (auto* type : manager->getAvailableDeviceTypes()) + { + if (type->getTypeName() != "ASIO") + continue; + type->scanForDevices(); + for (const auto& name : type->getDeviceNames(false)) + if (name.containsIgnoreCase(nameFragment) && type->getDeviceNames(true).contains(name)) + matches.addIfNotAlreadyThere(name); + } + if (matches.size() == 1) + selected = matches[0]; + report("unique_duplex_asio_device", selected.isNotEmpty(), matches.joinIntoString(", ")); + + juce::DynamicObject::Ptr before = new juce::DynamicObject(), beforeChannels = new juce::DynamicObject(); + const bool queried = selected.isNotEmpty() && settingsReadable + && ASIOCapabilities::query(selected, *before, *beforeChannels); + const double originalRate = static_cast(before->getProperty("reportedCurrentSampleRate")); + const int originalBuffer = static_cast(before->getProperty("reportedPreferredBufferSize")); + const int inputChannels = juce::jmin(2, static_cast(beforeChannels->getProperty("numInputChannels"))); + const int outputChannels = juce::jmin(2, static_cast(beforeChannels->getProperty("numOutputChannels"))); + const auto supportedBuffers = before->getProperty("bufferSizes"); + const bool restorable = queried && static_cast(before->getProperty("currentSampleRateReadbackValid")) + && std::isfinite(originalRate) && originalRate > 0.0 && originalBuffer > 0 + && inputChannels > 0 && outputChannels > 0; + const bool supported = supportedBuffers.isArray() && supportedBuffers.getArray()->contains(juce::var(8)) + && supportedBuffers.getArray()->contains(juce::var(16)) + && supportedBuffers.getArray()->contains(juce::var(64)); + report("inactive_driver_readback_before", restorable, juce::var(before.get())); + report("driver_advertises_8_16_and_64", supported, supportedBuffers); + + if (restorable && supported) + { + SilentCallback callback; + manager->addAudioCallback(&callback); + const auto apply = [&] (int buffer) + { + juce::AudioDeviceManager::AudioDeviceSetup setup; + setup.inputDeviceName = setup.outputDeviceName = selected; + setup.sampleRate = originalRate; + setup.bufferSize = buffer; + return AudioDeviceConfiguration::apply(*manager, "ASIO", setup, false, true, + [&] (auto& value) + { + value.inputChannels.setRange(0, inputChannels, true); + value.outputChannels.setRange(0, outputChannels, true); + value.useDefaultInputChannels = value.useDefaultOutputChannels = false; + }); + }; + bool restored = false; + const auto restore = [&] + { + const auto error = apply(originalBuffer); + auto* device = manager->getCurrentAudioDevice(); + const bool matchesOriginal = error.isEmpty() && device != nullptr + && std::abs(device->getCurrentSampleRate() - originalRate) <= 1.0 + && device->getCurrentBufferSizeSamples() == originalBuffer; + auto* detail = new juce::DynamicObject(); + detail->setProperty("error", error); + detail->setProperty("requestedSampleRate", originalRate); + detail->setProperty("requestedBufferSize", originalBuffer); + detail->setProperty("acceptedSampleRate", device != nullptr ? device->getCurrentSampleRate() : 0.0); + detail->setProperty("acceptedBufferSize", device != nullptr ? device->getCurrentBufferSizeSamples() : 0); + report("restore_original_driver_configuration", matchesOriginal, juce::var(detail)); + restored = true; + }; + const juce::ScopeGuard closeAndRestore {[&] + { + if (! restored) + restore(); + manager->removeAudioCallback(&callback); + manager->closeAudioDevice(); + }}; + int step = 0; + for (const int requestedBuffer : {8, 16, 64, 8}) + { + const auto error = apply(requestedBuffer); + auto* device = manager->getCurrentAudioDevice(); + const bool accepted = error.isEmpty() && device != nullptr + && std::abs(device->getCurrentSampleRate() - originalRate) <= 1.0 + && device->getCurrentBufferSizeSamples() == requestedBuffer; + callback.clearCounters(); + if (accepted) + juce::MessageManager::getInstance()->runDispatchLoopUntil(durationMilliseconds); + // Read again after callbacks so an asynchronous driver reset cannot + // turn a transient acceptance into a false success. + device = manager->getCurrentAudioDevice(); + const auto frames = callback.frames.load(std::memory_order_relaxed); + const auto blocks = callback.blocks.load(std::memory_order_relaxed); + const auto smallest = callback.minimumBlock.load(std::memory_order_relaxed); + const auto largest = callback.maximumBlock.load(std::memory_order_relaxed); + auto* detail = new juce::DynamicObject(); + detail->setProperty("requestedBufferSize", requestedBuffer); + detail->setProperty("error", error); + detail->setProperty("acceptedSampleRate", device != nullptr ? device->getCurrentSampleRate() : 0.0); + detail->setProperty("acceptedBufferSize", device != nullptr ? device->getCurrentBufferSizeSamples() : 0); + detail->setProperty("callbackFrames", frames); + detail->setProperty("callbackBlocks", blocks); + detail->setProperty("minimumCallbackBlock", blocks > 0 ? smallest : 0); + detail->setProperty("maximumCallbackBlock", largest); + detail->setProperty("driverXRunsDiagnostic", device != nullptr ? device->getXRunCount() : -1); + const bool stable = accepted && device != nullptr + && std::abs(device->getCurrentSampleRate() - originalRate) <= 1.0 + && device->getCurrentBufferSizeSamples() == requestedBuffer + && frames > 0 && blocks > 0 && smallest == requestedBuffer && largest == requestedBuffer; + report("buffer_step_" + juce::String(++step) + "_" + juce::String(requestedBuffer), stable, juce::var(detail)); + } + restore(); + } + + // Release JUCE's ASIO instance as well as its stream before querying an + // inactive driver again; some drivers reject simultaneous COM instances. + manager.reset(); + juce::DynamicObject::Ptr after = new juce::DynamicObject(), afterChannels = new juce::DynamicObject(); + const bool queriedAfter = restorable && ASIOCapabilities::query(selected, *after, *afterChannels); + report("inactive_driver_readback_after", queriedAfter + && static_cast(after->getProperty("currentSampleRateReadbackValid")) + && std::abs(static_cast(after->getProperty("reportedCurrentSampleRate")) - originalRate) <= 1.0 + && static_cast(after->getProperty("reportedPreferredBufferSize")) == originalBuffer, + juce::var(after.get())); + juce::MemoryBlock settingsAfter; + const bool settingsUnchanged = settingsReadable && settingsExisted == settingsFile.exists() + && (! settingsExisted || (settingsFile.loadFileAsData(settingsAfter) && settingsBefore == settingsAfter)); + report("saved_settings_byte_identical", settingsUnchanged, "The probe never loads settings into the manager or saves application settings."); + auto* result = new juce::DynamicObject(); + result->setProperty("success", passed); + result->setProperty("checks", checks); + result->setProperty("inputPolicy", "Physical input pointers are never read or recorded; all active output samples are cleared."); + result->setProperty("scope", "Silent ASIO device transaction 8 -> 16 -> 64 -> 8 at the driver's observed sample rate, followed by explicit restoration. No project or Rack is loaded."); + result->setProperty("audioQualityAndNAMCapacity", "not_asserted: this qualifies settings acceptance and callback block sizes only"); + result->setProperty("inactiveDriverReadback", "ASIO reports its preferred buffer when inactive; other audio clients must be closed before running."); + const bool written = directory.getChildFile("result.json").replaceWithText(juce::JSON::toString(juce::var(result), true)); + return passed && written ? 0 : 1; +} + class Capture final : public juce::AudioIODeviceCallback { public: diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index 449702c..3a4004e 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -24,10 +24,14 @@ #include "BuiltInEffects.h" #include "BuiltInUtilityEffects.h" #include "BuiltInEffects2.h" +#include "BuiltInReverbFactoryPresets.h" #include "NAMModelSafety.h" #include "PluginStateValidation.h" #include "SendGraphSafety.h" #include "NAMDelayRegression.h" +#include "NAMFuzzRegression.h" +#include "NAMNonlinearAliasAnalysis.h" +#include "NAMRackCpuRegression.h" #include "OpenStudioPitchCorrector.h" #include "PitchAnalyzer.h" #include "PitchResynthesizer.h" @@ -38,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -2869,7 +2874,8 @@ class ScopedAudioCallbackProcessingPause final public: ScopedAudioCallbackProcessingPause( std::atomic& processingPauseFlagToUse, - juce::AudioDeviceManager& deviceManager) + juce::AudioDeviceManager& deviceManager, + RealtimeTrackExecutor* executor = nullptr) : processingPauseFlag(processingPauseFlagToUse), previousPauseState( processingPauseFlag.exchange( @@ -2877,6 +2883,7 @@ class ScopedAudioCallbackProcessingPause final { const juce::ScopedLock callbackDrain( deviceManager.getAudioCallbackLock()); + if (executor != nullptr) executor->drain(); } ~ScopedAudioCallbackProcessingPause() @@ -3297,7 +3304,8 @@ void AudioEngine::publishRealtimeMonitoringSnapshot( snapshot.get(), std::memory_order_seq_cst); } -AudioEngine::AudioEngine() +AudioEngine::AudioEngine(DeviceStartup startup) + : deviceStartupMode(startup) { pluginAutomationClock->positionSource = ¤tSamplePosition; masterTrimVolumeAutomation.setDefaultValue(0.0f); @@ -3347,8 +3355,15 @@ AudioEngine::AudioEngine() // Now open the audio device and register the callback. This is done last so // that all members above are valid when audioDeviceAboutToStart() fires. - loadDeviceSettings(); - deviceManager.addAudioCallback (this); + if (startup == DeviceStartup::openHardware) + { + loadDeviceSettings(); + registerHardwareAudioCallback(); + } + else + { + juce::Logger::writeToLog("AudioEngine: Offline startup; hardware settings, device opens and callback registration skipped."); + } // Initialize Lua scripting engine (runs on message thread only) scriptEngine.registerAPI(*this); @@ -3357,6 +3372,13 @@ AudioEngine::AudioEngine() juce::Logger::writeToLog("AudioEngine: Lua ScriptEngine initialized"); } +void AudioEngine::registerHardwareAudioCallback() +{ + if (deviceStartupMode == DeviceStartup::offline) return; + hardwareCallbackRegistrationCount.fetch_add(1, std::memory_order_relaxed); + deviceManager.addAudioCallback(this); +} + AudioEngine::~AudioEngine() { // Stop/join external producers while every field they use is still alive. @@ -3371,6 +3393,8 @@ AudioEngine::~AudioEngine() controlSurfaceManager.setCallback(nullptr); deferredCallbacks.invalidate(); deviceManager.removeAudioCallback (this); + drainRealtimeTrackExecution(); + realtimeTrackExecutor.stop(); realtimeTrackSnapshotForAudio.store( nullptr, std::memory_order_seq_cst); realtimeMasterFXSnapshotForAudio.store( @@ -3876,6 +3900,10 @@ void AudioEngine::rebuildRealtimeProcessingSnapshots() entry.node = nodeIt->second; entry.processor = dynamic_cast(entry.node->getProcessor()); + entry.work = std::make_shared(); + entry.work->capacity = juce::jmax(1, currentBlockSize); + entry.work->buffer.setSize(TrackProcessor::maxProcessingChannels, entry.work->capacity); + entry.work->midi.ensureSize(131072); if (auto* track = entry.processor) { @@ -3901,6 +3929,7 @@ void AudioEngine::rebuildRealtimeProcessingSnapshots() sidechainOutputBuffers, sendAccumBuffers); trackSnapshot.processingOrder.resize(tracks.size()); + trackSnapshot.jobs = std::make_unique(tracks.size()); buildSidechainProcessingOrder( tracks, trackSnapshot.processingOrder.data(), @@ -4513,6 +4542,7 @@ void AudioEngine::registerWindowsAudioCallbackMMCSS() noexcept void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) { + drainRealtimeTrackExecution(); if (device == nullptr) { juce::Logger::writeToLog("AudioEngine ERROR: audioDeviceAboutToStart called with nullptr device!"); @@ -4641,7 +4671,6 @@ void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) * continuityRampSeconds)); // Covers bounded seek caches (including per-note pressure), the fixed input // queue and a full channel/note reset without growing these buffers at seek. - reusableRealtimeMidiBuffer.ensureSize(131072); reusableMasterMidiBuffer.ensureSize(131072); reusableMonitoringMidiBuffer.ensureSize(131072); for (auto& continuity : masterFXContinuity) @@ -4713,10 +4742,13 @@ void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) // this device. recalculatePDC(); } + realtimeTrackExecutor.prepare(requestedRealtimeWorkers); } void AudioEngine::audioDeviceStopped() { + drainRealtimeTrackExecution(); + realtimeTrackExecutor.stop(); instrumentPreview.stopAll(); irPreview.stopAll(); if (isRecordMode.load(std::memory_order_acquire)) @@ -4745,6 +4777,118 @@ void AudioEngine::audioDeviceStopped() } } +void AudioEngine::runParallelTrackJob(void* context) noexcept +{ + auto& work = *static_cast(context); + const ScopedPluginAutomationProcessing capture(work.position, work.rate, + work.automationEpoch, work.playing); + work.processed = work.processor->tryProcessBlockParallel(work.buffer, work.midi, + work.graphGeneration); +} + +void AudioEngine::drainRealtimeTrackExecution() +{ + realtimeTrackExecutor.drain(); + if (pendingParallelSnapshot != nullptr) + { + pendingParallelSnapshot = nullptr; + realtimeSnapshotAudioReaders.fetch_sub(1, std::memory_order_seq_cst); + } +} + +bool AudioEngine::setRealtimeTrackWorkerCount(int workers) +{ + if (workers < -1 || workers > RealtimeTrackExecutor::maximumWorkerCount) return false; + const ScopedAudioCallbackProcessingPause pause(isRendering, deviceManager, &realtimeTrackExecutor); + drainRealtimeTrackExecution(); + requestedRealtimeWorkers = workers; + return realtimeTrackExecutor.prepare(workers); +} + +bool AudioEngine::setRealtimeTrackWorkerSpinMicroseconds(int microseconds) +{ + const ScopedAudioCallbackProcessingPause pause(isRendering, deviceManager, &realtimeTrackExecutor); + return realtimeTrackExecutor.setWorkerSpinMicroseconds(microseconds); +} + +bool AudioEngine::setProbeTrackRecoveryGraceMicroseconds(int microseconds) +{ + if (deviceStartupMode != DeviceStartup::offline || microseconds < 0 || microseconds > 2000) + return false; + probeTrackRecoveryGraceMicroseconds.store(microseconds, std::memory_order_release); + return true; +} + +bool AudioEngine::setProbeNAMRackOversamplingFactor(const juce::String& trackId, int factor) +{ + // Probe-only request before device preparation; never change saved settings. + if (deviceStartupMode != DeviceStartup::offline || deviceManager.getCurrentAudioDevice() != nullptr + || isRecordMode.load() || isRendering.load() || (factor != 2 && factor != 4 && factor != 8)) + return false; + const auto owner = getPublishedBuiltInProcessor(trackId, "track", 0); + auto* rack = dynamic_cast(owner.processor.get()); + if (rack == nullptr) return false; + rack->setEmbeddedDriveOversamplingFactor(factor); + return rack->getEmbeddedDriveOversamplingFactor() == factor; +} + +juce::var AudioEngine::getRealtimeTrackExecutionDiagnostics() const +{ + auto result = realtimeTrackExecutor.getDiagnosticState(); + if (auto* object = result.getDynamicObject()) + { + object->setProperty("parallelTrackBlocks", static_cast(parallelTrackBlocks.load())); + object->setProperty("rejectedTrackJobs", static_cast(parallelTrackRejectedJobs.load())); + object->setProperty("quarantinedBlocks", static_cast(parallelQuarantinedBlocks.load())); + object->setProperty("lastFailureCallbackCounter", static_cast(parallelLastFailureCallback.load())); + object->setProperty("physicalCores", juce::SystemStats::getNumPhysicalCpus()); + object->setProperty("addedBufferingSamples", 0); + object->setProperty("recoveryGraceMicroseconds", probeTrackRecoveryGraceMicroseconds.load()); + } + return result; +} + +bool AudioEngine::setProbeRecordingDirectory(const juce::File& directory) +{ + if (deviceStartupMode != DeviceStartup::offline || isRecordMode.load() + || directory == juce::File()) return false; + probeRecordingDirectory = directory; + return true; +} + +void AudioEngine::recordQuarantinedTrackInput(const RealtimeTrackEntry& entry, + const float* const* inputs, int inputCount, int samples, juce::int64 blockPosition) +{ + // This is a recorder-only lane. It never accesses DSP buffers, MIDI queues, + // position setters, meters or any other worker-owned TrackProcessor state. + if (!isPlaying.load() || !isRecordMode.load() || recordingDeviceInterrupted.load()) return; + const double position = static_cast(blockPosition) / juce::jmax(1.0, currentSampleRate); + if (punchEnabled.load() && (position < punchStartTime.load() || position >= punchEndTime.load())) return; + const double compensated = juce::jmax(0.0, static_cast(blockPosition - inputLatencySamples) + / juce::jmax(1.0, currentSampleRate)); + auto* track = entry.processor; + if (track == nullptr || !track->getRecordArmed() || track->getTrackType() != TrackType::Audio) return; + const int start = track->getInputStartChannel(); + const int channels = juce::jmin(track->getInputChannelCount(), reusableTrackBuffer.getNumChannels()); + if (start < 0 || start >= inputCount || channels <= 0) return; + reusableTrackBuffer.clear(0, samples); + // A newer routing snapshot may add an incoming send after this processor + // entered the old, isolated batch. Only the callback writes this current + // accumulator; preserve its contribution without touching owned DSP data. + if (entry.sendAccumBuffer != nullptr && entry.sendAccumBuffer->getNumSamples() >= samples) + for (int channel = 0; channel < juce::jmin(2, reusableTrackBuffer.getNumChannels(), + entry.sendAccumBuffer->getNumChannels()); ++channel) + reusableTrackBuffer.addFrom(channel, 0, *entry.sendAccumBuffer, channel, 0, samples); + playbackEngine.fillTrackBuffer(entry.id, reusableTrackBuffer, + position - track->getPlaybackOffset() / 1000.0, samples, currentSampleRate); + const bool automationCanUnmute = track->getMuteAutomation().shouldPlayback() + && track->getMuteAutomation().hasPlaybackData(); + if (!track->getMute() || automationCanUnmute) + for (int channel = 0; channel < channels && start + channel < inputCount; ++channel) + reusableTrackBuffer.addFrom(channel, 0, inputs[start + channel], samples); + audioRecorder.writeBlock(entry.id, reusableTrackBuffer, samples, compensated); +} + void AudioEngine::audioDeviceError( const juce::String& errorMessage) { @@ -6568,22 +6712,16 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha ? juce::Time::getHighResolutionTicks() : 0; - // The immutable routing publication owns the sidechain/send topological - // order. The callback only walks that precomputed array. + // Both execution modes share input preparation, raw recording and final + // reduction. Only the independent per-track DSP runs on audio workers. bool anyActiveARAInCallback = false; - for (int orderIdx = 0; - orderIdx < rtTracks->processingOrderCount; - ++orderIdx) - { - const int trackIdx = - rtTracks->processingOrder[ - static_cast(orderIdx)]; - const auto& trackEntry = - rtTracks->tracks[static_cast(trackIdx)]; + const auto prepareTrack = [&](const RealtimeTrackEntry& trackEntry, + RealtimeTrackWork& work) -> bool + { const auto& trackId = trackEntry.id; auto* track = trackEntry.processor; if (!track) - continue; + return false; const bool hasActiveARA = track->hasActiveARA(); if (hasActiveARA) @@ -6609,7 +6747,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha if (excludedBySolo && !needsRecordingPath) { track->resetRMS(); - continue; + return false; } const bool isMidiTrack = track->getTrackType() == TrackType::MIDI @@ -6635,7 +6773,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha { // Safety check if (startChan < 0 || startChan >= numInputChannels) - continue; + return false; } // Skip this track if it's not playing clips and not eligible for live input. @@ -6651,35 +6789,19 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha false)) { track->resetRMS(); // Clear meter so it drops to zero when idle - continue; + return false; } - // Non-owning view of the pre-allocated buffer — avoids heap alloc on audio thread. - // ALWAYS 2 channels (stereo) for proper pan support. - // - // IMPORTANT: getWritePointer() is used (not getArrayOfWritePointers()) because - // it sets isClear=false on reusableTrackBuffer as a side effect. Without this, - // AudioBuffer's isClear optimisation makes clear() a no-op when a previous iteration - // left isClear=true, and makes applyGain/getRMSLevel skip processing (they also - // guard on isClear), causing stale signal to accumulate across track iterations. - // - // A callback larger than the device's prepared maximum is a host/device - // configuration fault. Resizing only this wrapper buffer here would - // allocate on the realtime thread while every TrackProcessor/plugin - // scratch buffer is still prepared for the old maximum. Omit the track - // for this exceptional block and expose it through diagnostics. - if (reusableTrackBuffer.getNumSamples() < numSamples || reusableTrackBuffer.getNumChannels() < 2) - { - audioCallbackTrackBufferResizeCount.fetch_add (1, std::memory_order_relaxed); + if (numSamples > work.capacity || track->getProcessingChannelCount() > TrackProcessor::maxProcessingChannels) + { + audioCallbackTrackBufferResizeCount.fetch_add(1, std::memory_order_relaxed); track->resetRMS(); - continue; + return false; } - - const int trackChannels = juce::jmin(track->getProcessingChannelCount(), reusableTrackBuffer.getNumChannels()); - std::array trackChans {}; - for (int channel = 0; channel < trackChannels; ++channel) - trackChans[static_cast(channel)] = reusableTrackBuffer.getWritePointer(channel); - juce::AudioBuffer trackBuffer(trackChans.data(), trackChannels, numSamples); + // Capacity is reserved during publication. avoidReallocating keeps + // active block/channel changes inside the owned allocation. + work.buffer.setSize(track->getProcessingChannelCount(), numSamples, false, false, true); + auto& trackBuffer = work.buffer; trackBuffer.clear(); // ========== MIX IN ACCUMULATED SENDS from other tracks ========== @@ -6753,9 +6875,10 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha < numInputChannels; ++ch) { - if (isPlaying) + if (isPlaying || trackEntry.hasIncomingSends) { - // Mix input with playback (overdub mode) + // Preserve accumulated sends while monitoring stopped, + // and playback material while overdubbing. juce::FloatVectorOperations::add( trackBuffer.getWritePointer(ch), inputChannelData[startChan + ch], @@ -6838,30 +6961,42 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha / juce::jmax( 1.0, currentSampleRate)); - // Immutable per-effect bindings are shared by playback and export. - track->setSidechainBuffers(&trackEntry.sidechainRoutes); // Process through track (applies volume, pan, FX, automation) - reusableRealtimeMidiBuffer.clear(); - track->buildMidiBuffer(reusableRealtimeMidiBuffer, + work.midi.clear(); + track->buildMidiBuffer(work.midi, blockStartTimeSeconds, numSamples, currentSampleRate, isPlaying.load()); - auto& midiMessages = reusableRealtimeMidiBuffer; + auto& midiMessages = work.midi; int prevMidiMax = midiMaxEventsPerBlock.load(std::memory_order_relaxed); while (midiMessages.getNumEvents() > prevMidiMax && !midiMaxEventsPerBlock.compare_exchange_weak(prevMidiMax, midiMessages.getNumEvents(), std::memory_order_relaxed)) { } - const bool trackProcessed = track->tryProcessBlock(trackBuffer, midiMessages); - track->setSidechainBuffers(nullptr); - if (!trackProcessed) - { - track->resetRMS(); - continue; - } + work.processor = track; + work.excludedBySolo = excludedBySolo; + work.shouldReadHardwareInput = shouldReadHardwareInput; + work.hasActiveARA = hasActiveARA; + work.position = blockStartTimeSeconds; + work.rate = currentSampleRate; + work.automationEpoch = blockCaptureEpoch; + work.playing = isPlaying.load(std::memory_order_acquire); + return true; + }; + const auto reduceTrack = [&](const RealtimeTrackEntry& trackEntry, + RealtimeTrackWork& work) + { + auto* track = trackEntry.processor; + const auto& trackId = trackEntry.id; + auto& trackBuffer = work.buffer; + auto& midiMessages = work.midi; + const bool excludedBySolo = work.excludedBySolo; + const bool shouldReadHardwareInput = work.shouldReadHardwareInput; + const bool hasActiveARA = work.hasActiveARA; + juce::ignoreUnused(trackId, shouldReadHardwareInput, hasActiveARA); if (shouldLogCallbackSummary && isPlaying.load()) { OPENSTUDIO_LOG_AUDIO_ENGINE_PLAYBACK("track stage track=" + trackId @@ -6962,6 +7097,143 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } } } + }; + + // A worker which exceeded its collection deadline still owns its buffers + // and processor. Never touch a queued lane's block state until the entire + // batch retires, including lanes already completed in that same batch. + if (pendingParallelSnapshot != nullptr && realtimeTrackExecutor.isIdle()) + { + pendingParallelSnapshot = nullptr; + realtimeSnapshotAudioReaders.fetch_sub(1, std::memory_order_seq_cst); + } + if (pendingParallelSnapshot != nullptr) + { + parallelQuarantinedBlocks.fetch_add(1, std::memory_order_relaxed); + parallelLastFailureCallback.store(callbackCounter, std::memory_order_relaxed); + for (int orderIdx = 0; orderIdx < rtTracks->processingOrderCount; ++orderIdx) + { + const auto& entry = rtTracks->tracks[static_cast(rtTracks->processingOrder[static_cast(orderIdx)])]; + // The old snapshot is pinned and jobIndex was frozen before + // dispatch. Check identity without reading worker-owned results, + // even if control changes have published a newer current graph. + const bool ownedByPendingBatch = std::any_of(pendingParallelSnapshot->tracks.begin(), + pendingParallelSnapshot->tracks.end(), [&](const RealtimeTrackEntry& pendingEntry) + { return pendingEntry.processor == entry.processor && pendingEntry.work->jobIndex >= 0; }); + if (ownedByPendingBatch) + { + recordQuarantinedTrackInput(entry, inputChannelData, numInputChannels, + numSamples, blockStartSamplePosition); + continue; + } + // Keep unrelated sends, sidechains and recorder taps in their + // existing dependency order. No new worker batch can start while + // the previous batch still owns any processor or scratch buffer. + auto& work = *entry.work; + work.prepared = prepareTrack(entry, work); + if (!work.prepared) continue; + entry.processor->setSidechainBuffers(&entry.sidechainRoutes); + work.processed = entry.processor->tryProcessBlock(work.buffer, work.midi); + entry.processor->setSidechainBuffers(nullptr); + if (work.processed) reduceTrack(entry, work); + else entry.processor->resetRMS(); + } + } + else + { + int independentTracks = 0; + for (const auto& entry : rtTracks->tracks) + { + auto& work = *entry.work; + work.prepared = false; + work.processed = false; + work.jobIndex = -1; + // Only isolated native NAM lanes can move ahead of the existing + // dependency order. Hosted/MIDI/ARA and connected tracks retain + // their callback-thread processing and routing order below. + const bool isolated = entry.sidechainRoutes.empty() && entry.sends.empty() + && !entry.hasIncomingSends && entry.sidechainOutputBuffer == nullptr; + work.graphGeneration = isolated && entry.processor != nullptr + ? entry.processor->getParallelNAMGraphGeneration() : 0; + if (work.graphGeneration != 0) ++independentTracks; + } + const bool parallelEligible = realtimeTrackExecutor.getWorkerCount() > 0 + && independentTracks > 1 + && independentTracks <= RealtimeTrackExecutor::maximumJobCount; + int jobCount = 0; + if (parallelEligible) + { + for (int orderIdx = 0; orderIdx < rtTracks->processingOrderCount; ++orderIdx) + { + const auto& entry = rtTracks->tracks[static_cast(rtTracks->processingOrder[static_cast(orderIdx)])]; + auto& work = *entry.work; + if (work.graphGeneration == 0) continue; + work.prepared = prepareTrack(entry, work); + if (!work.prepared) continue; + work.jobIndex = jobCount; + auto& job = rtTracks->jobs[static_cast(jobCount++)]; + job.context = &work; + job.run = &AudioEngine::runParallelTrackJob; + } + } + if (jobCount > 0) + { + // Pin before publication: a worker can be descheduled before it + // enters TrackProcessor's own graph reader scope. + realtimeSnapshotAudioReaders.fetch_add(1, std::memory_order_seq_cst); + pendingParallelSnapshot = rtTracks; + const auto now = juce::Time::getHighResolutionTicks(); + const double elapsedMs = juce::Time::getMillisecondCounterHiRes() - callbackStartWallTimeMs; + // This is a collection target, not a guarantee for the remaining + // serial/master work. The full callback deadline meter includes + // those stages and reports any overrun independently. + const double remainingMs = juce::jmax(0.0, callbackBlockDurationMs - 0.012 - elapsedMs); + const auto cutoff = now + static_cast(remainingMs * 0.001 + * static_cast(juce::Time::getHighResolutionTicksPerSecond())); + const auto hardCutoff = cutoff + + static_cast(probeTrackRecoveryGraceMicroseconds.load(std::memory_order_acquire)) + * juce::Time::getHighResolutionTicksPerSecond() / 1000000; + if (!realtimeTrackExecutor.runBatch(rtTracks->jobs.get(), jobCount, cutoff, hardCutoff)) + parallelLastFailureCallback.store(callbackCounter, std::memory_order_relaxed); + parallelTrackBlocks.fetch_add(1, std::memory_order_relaxed); + } + // Keep every reduction in the original order. Serial sends/sidechains + // therefore reach their destinations before preparation, and Float32 + // and Hybrid64 master sums retain their original accumulation order. + for (int orderIdx = 0; orderIdx < rtTracks->processingOrderCount; ++orderIdx) + { + const auto& entry = rtTracks->tracks[static_cast(rtTracks->processingOrder[static_cast(orderIdx)])]; + auto& work = *entry.work; + if (parallelEligible && work.graphGeneration != 0) + { + if (!work.prepared) continue; + // Done is published after run() and all its processor/context + // guards return. Consume completed lanes even when another + // lane ran late; running lanes retain exclusive ownership. + if (rtTracks->jobs[static_cast(work.jobIndex)].state.load(std::memory_order_acquire) + != RealtimeTrackExecutor::State::done) continue; + if (!work.processed) + { + parallelTrackRejectedJobs.fetch_add(1, std::memory_order_relaxed); + parallelLastFailureCallback.store(callbackCounter, std::memory_order_relaxed); + } + } + else + { + work.prepared = prepareTrack(entry, work); + if (!work.prepared) continue; + entry.processor->setSidechainBuffers(&entry.sidechainRoutes); + work.processed = entry.processor->tryProcessBlock(work.buffer, work.midi); + entry.processor->setSidechainBuffers(nullptr); + } + if (work.processed) reduceTrack(entry, work); + else entry.processor->resetRMS(); + } + if (pendingParallelSnapshot != nullptr && realtimeTrackExecutor.isIdle()) + { + pendingParallelSnapshot = nullptr; + realtimeSnapshotAudioReaders.fetch_sub(1, std::memory_order_seq_cst); + } } if (shouldSampleAudioCallbackStages) @@ -6994,10 +7266,11 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } else { - reusablePitchScrubBuffer.clear(); + reusablePitchScrubBuffer.clear(0, numSamples); playbackEngine.renderPitchScrubPreview ( reusablePitchScrubBuffer, - currentSampleRate); + currentSampleRate, + numSamples); if (numOutputChannels > 0) { if (useHybrid64Summing) @@ -7031,9 +7304,12 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // Isolated instrument audition joins only the live monitor/master path. // Track recording queues and offline rendering never see these notes. - if (reusablePitchScrubBuffer.getNumSamples() >= numSamples && reusablePitchScrubBuffer.getNumChannels() >= 2) + if (instrumentPreview.mayRender() + && reusablePitchScrubBuffer.getNumSamples() >= numSamples + && reusablePitchScrubBuffer.getNumChannels() >= 2) { - reusablePitchScrubBuffer.clear(); + for (int channel = 0; channel < 2; ++channel) + reusablePitchScrubBuffer.clear(channel, 0, numSamples); instrumentPreview.render(reusablePitchScrubBuffer.getArrayOfWritePointers(), 2, numSamples, currentSampleRate); for (int ch = 0; ch < juce::jmin(2, numOutputChannels); ++ch) for (int i = 0; i < numSamples; ++i) @@ -7095,9 +7371,12 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // IR audition is already rendered wet-only. Bypass every FX chain so a // master/monitor-hosted source cannot process its own preview a second time. - if(reusablePitchScrubBuffer.getNumSamples()>=numSamples&&reusablePitchScrubBuffer.getNumChannels()>=2) + if (irPreview.mayRender() + && reusablePitchScrubBuffer.getNumSamples() >= numSamples + && reusablePitchScrubBuffer.getNumChannels() >= 2) { - reusablePitchScrubBuffer.clear(); + for (int channel = 0; channel < 2; ++channel) + reusablePitchScrubBuffer.clear(channel, 0, numSamples); irPreview.render(reusablePitchScrubBuffer.getArrayOfWritePointers(),2,numSamples,currentSampleRate); for(int ch=0;chgetCallbackLock()); auto it = trackMap.find(trackId); @@ -8310,6 +8590,10 @@ juce::var AudioEngine::queryAudioDeviceSetup(const juce::var& request) // Message-thread discovery only. Never open/start a probe or query a streaming // device: some drivers cannot tolerate a second instance of themselves. jassert(juce::MessageManager::getInstance()->isThisTheMessageThread()); + juce::Logger::writeToLog("AudioDeviceSetup query: type=" + + request.getProperty("audioDeviceType", {}).toString() + + " requestedRate=" + request.getProperty("sampleRate", 0).toString() + + " requestedBuffer=" + request.getProperty("bufferSize", 0).toString()); auto result = getAudioDeviceSetup().clone(); auto* data = result.getDynamicObject(); auto current = result.getProperty("current", {}).clone(); @@ -8325,10 +8609,12 @@ juce::var AudioEngine::queryAudioDeviceSetup(const juce::var& request) juce::Array adjustments; auto fail = [&](const juce::String& message) { + juce::Logger::writeToLog("AudioDeviceSetup query failed: " + message); data->setProperty("error", message); data->setProperty("capabilityStatus", "unavailable"); data->setProperty("sampleRates", juce::Array()); data->setProperty("bufferSizes", juce::Array()); + data->setProperty("current", current); return result; }; if (selectedType == nullptr) return fail("The selected audio system is unavailable."); @@ -8343,26 +8629,35 @@ juce::var AudioEngine::queryAudioDeviceSetup(const juce::var& request) }; data->setProperty("inputs", asValues(inputs)); data->setProperty("outputs", asValues(outputs)); + const bool useDefaultDevices = typeName != current.getProperty("audioDeviceType", {}).toString(); auto selectDevice = [&](const char* key, const juce::StringArray& names, bool input) { - const auto requested = request.getProperty(key, {}).toString(); - if (names.contains(requested)) return requested; - const auto index = selectedType->getDefaultDeviceIndex(input); - const auto resolved = names.isEmpty() ? juce::String() : names[juce::jlimit(0, names.size() - 1, index)]; - if (requested != resolved) adjustments.add(juce::String(input ? "Input" : "Output") + " device changed to " + (resolved.isEmpty() ? "unavailable" : resolved) + "."); - return resolved; + return AudioDeviceConfiguration::resolveDeviceName( + request, juce::Identifier(key), names, selectedType->getDefaultDeviceIndex(input), useDefaultDevices); }; auto input = selectDevice("inputDevice", inputs, true); auto output = selectDevice("outputDevice", outputs, false); - if (!selectedType->hasSeparateInputsAndOutputs()) input = output; + if (!selectedType->hasSeparateInputsAndOutputs()) + { + const auto names = AudioDeviceConfiguration::resolveCoupledDeviceNames( + request, input, output, useDefaultDevices); + input = names.input; + output = names.output; + } + if (request.getProperty("inputDevice", {}).toString() != input) + adjustments.add("Input device changed to " + (input.isEmpty() ? juce::String("unavailable") : input) + "."); + if (request.getProperty("outputDevice", {}).toString() != output) + adjustments.add("Output device changed to " + (output.isEmpty() ? juce::String("unavailable") : output) + "."); const bool sameDevice = deviceManager.getCurrentAudioDevice() != nullptr - && typeName == current.getProperty("audioDeviceType", {}).toString() - && input == current.getProperty("inputDevice", {}).toString() - && output == current.getProperty("outputDevice", {}).toString(); + && AudioDeviceConfiguration::isSameStreamingDevice( + typeName, input, output, + current.getProperty("audioDeviceType", {}).toString(), + current.getProperty("inputDevice", {}).toString(), + current.getProperty("outputDevice", {}).toString(), + selectedType->hasSeparateInputsAndOutputs()); draft->setProperty("audioDeviceType", typeName); draft->setProperty("inputDevice", input); draft->setProperty("outputDevice", output); - data->setProperty("current", current); if (!sameDevice) { @@ -8375,7 +8670,8 @@ juce::var AudioEngine::queryAudioDeviceSetup(const juce::var& request) { if (isTransportRecording() || isRendering.load()) return fail("Stop recording or rendering before inspecting another ASIO driver."); - if (!ASIOCapabilities::query(output, *data, *draft)) + const auto driverName = output.isNotEmpty() ? output : input; + if (!ASIOCapabilities::query(driverName, *data, *draft)) return fail("The ASIO driver could not report its settings without activation. Check that it is connected and not in use by another application."); data->setProperty("capabilityMessage", "Driver-reported options and preferred settings. Apply verifies the selected configuration; buffer restrictions can depend on sample rate."); } @@ -8417,6 +8713,13 @@ juce::var AudioEngine::queryAudioDeviceSetup(const juce::var& request) || static_cast(current.getProperty("bufferSize", 0)) <= 0)) return fail("No compatible rates and buffers were reported for this device pair."); data->setProperty("adjustments", adjustments); + juce::Logger::writeToLog("AudioDeviceSetup query resolved: rate=" + + current.getProperty("sampleRate", 0).toString() + + " buffer=" + current.getProperty("bufferSize", 0).toString() + + " sameStreamingDevice=" + juce::String(sameDevice ? "true" : "false")); + // Publish after all edits. JUCE compares DynamicObjects by value, so setting + // an equal clone before mutation can leave the old object in the response. + data->setProperty("current", current); return result; } @@ -8582,6 +8885,9 @@ juce::var AudioEngine::buildAudioDeviceSetupSnapshot( juce::var AudioEngine::openAudioDeviceControlPanel() { + const juce::ScopeGuard refreshActualSetup { [this] { + refreshAudioDeviceSetupSnapshot(deviceManager.getCurrentAudioDevice()); + } }; auto* response = new juce::DynamicObject(); response->setProperty("success", false); response->setProperty("opened", false); @@ -8630,7 +8936,6 @@ juce::var AudioEngine::openAudioDeviceControlPanel() response->setProperty("sampleRate", device->getCurrentSampleRate()); response->setProperty("bufferSize", device->getCurrentBufferSizeSamples()); } - refreshAudioDeviceSetupSnapshot(device); response->setProperty("success", true); return juce::var(response); } @@ -8692,7 +8997,10 @@ bool AudioEngine::applyAudioDeviceSetup( juce::String& errorMessage, bool useDefaultDevices) { - juce::Logger::writeToLog("AudioEngine: Setting Audio Device..."); + juce::Logger::writeToLog("AudioDeviceSetup apply: type=" + type + + " input=" + input + " output=" + output + + " requestedRate=" + juce::String(sampleRate, 0) + + " requestedBuffer=" + juce::String(bufferSize)); if (isTransportRecording() || isRendering.load()) { @@ -8712,6 +9020,7 @@ bool AudioEngine::applyAudioDeviceSetup( // default. Preserve Automatic's conservative targets before the transaction. setup.sampleRate = sampleRate > 0 ? sampleRate : 48000.0; setup.bufferSize = bufferSize > 0 ? bufferSize : 512; + juce::AudioDeviceManager::AudioDeviceSetup acceptedSetup; errorMessage = AudioDeviceConfiguration::apply( deviceManager, type, setup, useDefaultDevices, isMicrophonePermissionGrantedForInput(), @@ -8720,11 +9029,21 @@ bool AudioEngine::applyAudioDeviceSetup( applyRoutedDeviceChannelPolicy( requested, requested.inputDeviceName.isNotEmpty() ? 2 : 0, requested.outputDeviceName.isNotEmpty() ? 2 : 0); - }); + }, sampleRate > 0, bufferSize > 0, &acceptedSetup); + juce::Logger::writeToLog("AudioDeviceSetup accepted: rate=" + + juce::String(acceptedSetup.sampleRate, 0) + + " buffer=" + juce::String(acceptedSetup.bufferSize) + + " success=" + juce::String(errorMessage.isEmpty() ? "true" : "false") + + (errorMessage.isEmpty() ? juce::String() : " error=" + errorMessage)); // Failed opens may stop the callback; publish the rolled-back setup too. refreshAudioDeviceSetupSnapshot(deviceManager.getCurrentAudioDevice()); if (errorMessage.isNotEmpty()) { + const auto restored = deviceManager.getAudioDeviceSetup(); + juce::Logger::writeToLog("AudioDeviceSetup after failed apply: rate=" + + juce::String(restored.sampleRate, 0) + + " buffer=" + juce::String(restored.bufferSize) + + " deviceOpen=" + juce::String(deviceManager.getCurrentAudioDevice() != nullptr ? "true" : "false")); juce::Logger::writeToLog("AudioEngine: Error setting device: " + errorMessage); return false; } @@ -9336,7 +9655,8 @@ void AudioEngine::setTransportRecording(bool recording) } if (recording) { - const auto destination = getPreferredAppDataDirectory().getChildFile("Audio"); + const auto destination = probeRecordingDirectory != juce::File() + ? probeRecordingDirectory : getPreferredAppDataDirectory().getChildFile("Audio"); const auto ready = prepareRecordingDirectory(destination); if (ready.failed()) { @@ -9626,7 +9946,7 @@ juce::var AudioEngine::runEngineBenchmarks() const juce::ScopedLock offlineRenderTransaction( offlineRenderTransactionLock); const ScopedAudioCallbackProcessingPause realtimeProcessingPause( - isRendering, deviceManager); + isRendering, deviceManager, &realtimeTrackExecutor); auto* root = new juce::DynamicObject(); @@ -9848,7 +10168,7 @@ void AudioEngine::setProcessingPrecision(const juce::String& precisionModeString // callbacks on the existing early-out until the replacement publications // are complete. const ScopedAudioCallbackProcessingPause realtimeProcessingPause( - isRendering, deviceManager); + isRendering, deviceManager, &realtimeTrackExecutor); ProcessingPrecisionMode newMode = precisionModeString == "hybrid64" ? ProcessingPrecisionMode::Hybrid64 @@ -10414,6 +10734,72 @@ class ScopedBuiltInPresetTestDirectories juce::var AudioEngine::runNAMRackRegression() { + const auto finaliseFocusedResult = [this] (juce::var result) + { + const bool deviceOpen = deviceManager.getCurrentAudioDevice() != nullptr; + const auto starts = audioDeviceStartCount.load(std::memory_order_relaxed); + const auto callbacks = audioCallbackCounter.load(std::memory_order_relaxed); + const bool offline = !deviceOpen && starts == 0 && callbacks == 0; + if (auto* object = result.getDynamicObject()) + { + object->setProperty("audioDeviceOpened", deviceOpen); + object->setProperty("audioDeviceStartCount", static_cast(starts)); + object->setProperty("audioCallbackCount", static_cast(callbacks)); + auto checks = object->getProperty("checks"); + if (!checks.isArray()) checks = juce::Array(); + auto* check = new juce::DynamicObject(); + check->setProperty("id", "focused_nam_no_hardware_device_opened"); + check->setProperty("status", offline ? "pass" : "fail"); + check->setProperty("detail", "Focused NAM QA requires no current audio device, zero device starts and zero hardware callbacks."); + checks.getArray()->add(check); + object->setProperty("checks", checks); + if (!offline) + { + object->setProperty("objectiveGateStatus", "fail"); + object->setProperty("success", false); + object->setProperty("done", false); + } + } + return result; + }; + const bool fuzzOnly = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_ONLY", {}) == "1"; + const bool twoRackCpuOnly = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_TWO_RACK_CPU_ONLY", {}) == "1"; + if ((fuzzOnly || twoRackCpuOnly) + && (deviceManager.getCurrentAudioDevice() != nullptr + || audioDeviceStartCount.load(std::memory_order_relaxed) != 0 + || audioCallbackCounter.load(std::memory_order_relaxed) != 0)) + { + auto* result = new juce::DynamicObject(); + result->setProperty("objectiveGateStatus", "fail"); + result->setProperty("subjectiveQuality", "not_asserted"); + result->setProperty("summary", "Focused NAM QA refused to run after hardware audio startup; use the focused headless command."); + return finaliseFocusedResult(juce::var(result)); + } + // Opt-in focused QA uses the production fuzz and octave paths without + // running unrelated rack/model fixtures or opening an audio device. + if (fuzzOnly) + { + auto checks = NAMFuzzRegression::run(*this); + const auto profileCopy = runNAMRackPresetProfileCopyRegression(); + const auto profileCopyChecks = profileCopy.getProperty("checks", {}); + if (const auto* profileChecks = profileCopyChecks.getArray()) + for (const auto& check : *profileChecks) + checks.add(check); + else + NAMRackCpuRegression::addCheck(checks, "nam_profile_copy_result", "fail", "Preset profile copy regression did not return its checks array."); + bool passed = static_cast(profileCopy.getProperty("success", false)); + for (const auto& check : checks) + passed = passed && check.getProperty("status", {}).toString() != "fail"; + auto* result = new juce::DynamicObject(); + result->setProperty("objectiveGateStatus", passed ? "pass" : "fail"); + result->setProperty("subjectiveQuality", "not_asserted"); + result->setProperty("summary", "Focused NAM Octaver Fuzz contracts; optional interaction measurements and listening exports are diagnostic only."); + result->setProperty("checks", juce::var(checks)); + result->setProperty("success", passed); + result->setProperty("done", passed); + return finaliseFocusedResult(juce::var(result)); + } + // Keep sizeable rack fixtures off the aggregate regression stack. The // expanded matrix exceeded safe headroom even with an 8 MiB Windows stack. // These allocations are test setup, never the realtime processing path. @@ -10616,6 +11002,49 @@ juce::var AudioEngine::runNAMRackRegression() rack.reverbEnabled.store(1.0f); }; + if (twoRackCpuOnly) + { + const auto resamplerParity = NAMRackCpuRegression::runResamplerParity< + OpenStudioNAMRack::NAMResamplerKernel, OpenStudioNAMRack::NAMResamplerState>(); + const auto delayParity = NAMRackCpuRegression::runInactiveDelayParity( + [] (OpenStudioNAMRack& rack, juce::AudioBuffer& block, juce::MidiBuffer& midi) + { + rack.processDelayStage(block, midi); + }, + [] (const OpenStudioNAMRack& rack) + { + return std::array { + static_cast(rack.delayTailSamplesRemaining), + static_cast(rack.publishedDelayTailSeconds.load(std::memory_order_relaxed)), + rack.delayWasActive ? 1.0 : 0.0 + }; + }); + const auto driveParity = NAMRackCpuRegression::runDriveCacheParity([] (OpenStudioNAMRack& rack) + { + rack.invalidateEmbeddedDriveCoefficientCaches(); + }); + const bool contractsPassed = static_cast(resamplerParity.getProperty("pass", false)) + && static_cast(delayParity.getProperty("pass", false)) && static_cast(driveParity.getProperty("pass", false)); + juce::var result; + if (!contractsPassed || juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_TWO_RACK_CPU_CONTRACTS_ONLY", {}) == "1") + { + auto* report = new juce::DynamicObject(); + report->setProperty("objectiveGateStatus", "pass"); + report->setProperty("subjectiveQuality", "not_asserted"); + report->setProperty("timingStatus", "not_asserted"); + report->setProperty("summary", "Focused NAM resampler reference parity and inactive Delay contracts; no models or audio devices required."); + result = juce::var(report); + } + else + { + const auto scenarios = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_CPU_SCENARIOS", {}).trim(); + result = scenarios.isEmpty() + ? NAMRackCpuRegression::run(findNAMCoreExample, configureRack, writeFixtureIR) + : NAMRackCpuRegression::runScenarios(juce::File::getCurrentWorkingDirectory().getChildFile(scenarios)); + } + return finaliseFocusedResult(NAMRackCpuRegression::withOptimizationContracts(result, resamplerParity, delayParity, driveParity)); + } + auto fillInput = [&] (juce::AudioBuffer& buffer, int blockIndex, float inputScale = 1.0f) @@ -18218,9 +18647,7 @@ juce::var AudioEngine::runNAMRackRegression() constexpr int referenceRateFactor = 4; constexpr int referenceOversamplingFactor = 8; constexpr int decimatorTapCount = 513; - constexpr int decimatorHalfLength = decimatorTapCount / 2; constexpr int occupiedBinStride = 11; - constexpr double analysisPeriodSeconds = 0.010; constexpr double sourceDurationSeconds = 0.28; constexpr std::array sampleRates { 44100.0, 48000.0, 96000.0 @@ -18236,68 +18663,10 @@ juce::var AudioEngine::runNAMRackRegression() 0.17, 1.13, 2.41, -0.71 }; - std::vector decimatorCoefficients( - static_cast(decimatorTapCount), 0.0); - constexpr double normalizedCutoff = - 0.45 / static_cast(referenceRateFactor); - double coefficientSum = 0.0; - for (int tap = 0; tap < decimatorTapCount; ++tap) - { - const int offset = tap - decimatorHalfLength; - const double sincArgument = - 2.0 * normalizedCutoff * static_cast(offset); - const double sinc = offset == 0 - ? 1.0 - : std::sin(juce::MathConstants::pi * sincArgument) - / (juce::MathConstants::pi * sincArgument); - const double phase = static_cast(tap) - / static_cast(decimatorTapCount - 1); - const double window = 0.35875 - - 0.48829 * std::cos( - juce::MathConstants::twoPi * phase) - + 0.14128 * std::cos( - 2.0 * juce::MathConstants::twoPi * phase) - - 0.01168 * std::cos( - 3.0 * juce::MathConstants::twoPi * phase); - const double coefficient = - 2.0 * normalizedCutoff * sinc * window; - decimatorCoefficients[static_cast(tap)] = coefficient; - coefficientSum += coefficient; - } - for (auto& coefficient : decimatorCoefficients) - coefficient /= coefficientSum; - - const auto decimate = [&decimatorCoefficients] ( - const std::vector& highRate) - { - std::vector result; - if (highRate.size() - <= decimatorCoefficients.size()) - { - return result; - } - const int lastCentre = static_cast(highRate.size()) - - 256 - 1; - const int outputCount = 1 - + (lastCentre - 256) / 4; - result.reserve(static_cast(juce::jmax(0, outputCount))); - for (int centre = 256; - centre <= lastCentre; - centre += 4) - { - double sum = 0.0; - const int first = centre - 256; - for (int tap = 0; - tap < static_cast(decimatorCoefficients.size()); - ++tap) - { - sum += decimatorCoefficients[static_cast(tap)] - * static_cast( - highRate[static_cast(first + tap)]); - } - result.push_back(static_cast(sum)); - } - return result; + const NAMNonlinearAliasAnalysis aliasAnalysis(referenceRateFactor); + const auto decimate = [&aliasAnalysis] (const std::vector& highRate) + { + return aliasAnalysis.decimate(highRate); }; struct AliasRender @@ -18398,178 +18767,8 @@ juce::var AudioEngine::runNAMRackRegression() return result; }; - const auto makeSpectrum = [] (const std::vector& period) - { - const int count = static_cast(period.size()); - std::vector> spectrum( - static_cast(count / 2 + 1)); - for (int bin = 0; bin <= count / 2; ++bin) - { - const double angle = - -juce::MathConstants::twoPi - * static_cast(bin) - / static_cast(count); - const std::complex step( - std::cos(angle), std::sin(angle)); - std::complex oscillator(1.0, 0.0); - std::complex sum(0.0, 0.0); - for (const float sample : period) - { - sum += static_cast(sample) * oscillator; - oscillator *= step; - } - spectrum[static_cast(bin)] = sum; - } - return spectrum; - }; - - struct AlignedResidual - { - double unoccupiedRatio = 1.0; - double occupiedRatio = 1.0; - double fractionalAdvance = 0.0; - int integerAdvance = 0; - int unoccupiedBins = 0; - int occupiedBins = 0; - bool finite = false; - }; - - const auto measureAlignedResidual = [&] ( - const std::vector& device, - const std::vector& reference, - double sampleRate, - double expectedAdvance, - int occupiedStride) - { - AlignedResidual result; - const int available = juce::jmin( - static_cast(device.size()), - static_cast(reference.size())); - const int periodSamples = juce::jmax( - 32, - static_cast(std::round( - sampleRate * analysisPeriodSeconds))); - result.integerAdvance = static_cast( - std::round(expectedAdvance)); - const int margin = std::abs(result.integerAdvance) + 8; - const int referenceStart = available - margin - periodSamples; - const int deviceStart = - referenceStart + result.integerAdvance; - if (referenceStart < 0 - || deviceStart < 0 - || referenceStart + periodSamples > available - || deviceStart + periodSamples > available) - { - return result; - } - - std::vector devicePeriod( - static_cast(periodSamples)); - std::vector referencePeriod( - static_cast(periodSamples)); - for (int sample = 0; sample < periodSamples; ++sample) - { - devicePeriod[static_cast(sample)] = - device[static_cast(deviceStart + sample)]; - referencePeriod[static_cast(sample)] = - reference[static_cast(referenceStart + sample)]; - } - const auto deviceSpectrum = makeSpectrum(devicePeriod); - const auto referenceSpectrum = makeSpectrum(referencePeriod); - double maximumReferenceMagnitude = 0.0; - for (size_t bin = 1; bin < referenceSpectrum.size(); ++bin) - { - if (static_cast(bin) % occupiedStride != 0) - continue; - maximumReferenceMagnitude = juce::jmax( - maximumReferenceMagnitude, - std::abs(referenceSpectrum[bin])); - } - const double occupiedFloor = juce::jmax( - 1.0e-14, maximumReferenceMagnitude * 1.0e-5); - - double bestError = std::numeric_limits::max(); - for (int stepIndex = -128; stepIndex <= 128; ++stepIndex) - { - const double fractional = - static_cast(stepIndex) / 256.0; - double error = 0.0; - int bins = 0; - for (size_t bin = 1; bin < referenceSpectrum.size(); ++bin) - { - const double frequency = static_cast(bin) - * sampleRate / static_cast(periodSamples); - if (frequency < 20.0 - || frequency > sampleRate * 0.45 - || static_cast(bin) % occupiedStride != 0 - || std::abs(referenceSpectrum[bin]) < occupiedFloor) - { - continue; - } - const double phase = - juce::MathConstants::twoPi - * static_cast(bin) * fractional - / static_cast(periodSamples); - const std::complex rotated = - deviceSpectrum[bin] - * std::complex( - std::cos(phase), std::sin(phase)); - error += std::norm(rotated - referenceSpectrum[bin]); - ++bins; - } - if (bins > 0 && error < bestError) - { - bestError = error; - result.fractionalAdvance = fractional; - } - } - - double referencePower = 0.0; - double occupiedResidualPower = 0.0; - double unoccupiedResidualPower = 0.0; - for (size_t bin = 1; bin < referenceSpectrum.size(); ++bin) - { - const double frequency = static_cast(bin) - * sampleRate / static_cast(periodSamples); - if (frequency < 20.0 || frequency > sampleRate * 0.45) - continue; - const double phase = - juce::MathConstants::twoPi - * static_cast(bin) - * result.fractionalAdvance - / static_cast(periodSamples); - const std::complex rotated = deviceSpectrum[bin] - * std::complex( - std::cos(phase), std::sin(phase)); - const double residual = std::norm( - rotated - referenceSpectrum[bin]); - const bool analyticallyOccupied = - static_cast(bin) % occupiedStride == 0; - if (analyticallyOccupied) - { - referencePower += std::norm(referenceSpectrum[bin]); - occupiedResidualPower += residual; - ++result.occupiedBins; - } - else - { - unoccupiedResidualPower += residual; - ++result.unoccupiedBins; - } - } - result.unoccupiedRatio = std::sqrt( - unoccupiedResidualPower - / juce::jmax(1.0e-30, referencePower)); - result.occupiedRatio = std::sqrt( - occupiedResidualPower - / juce::jmax(1.0e-30, referencePower)); - result.finite = result.unoccupiedBins > 0 - && result.occupiedBins > 0 - && std::isfinite(result.unoccupiedRatio) - && std::isfinite(result.occupiedRatio) - && std::isfinite(result.fractionalAdvance); - return result; - }; + const auto measureAlignedResidual = + NAMNonlinearAliasAnalysis::measureAlignedResidual; juce::Array rateValues; bool allProductionCasesPass = true; @@ -56172,7 +56371,7 @@ juce::var AudioEngine::runNAMRackRegression() // Keep the hardware callback paused across both counter reads. // renderProject resumes its caller's prior pause state; otherwise // live monitoring can increment this counter immediately after export. - const ScopedAudioCallbackProcessingPause monitoringFixturePause(isRendering, deviceManager); + const ScopedAudioCallbackProcessingPause monitoringFixturePause(isRendering, deviceManager, &realtimeTrackExecutor); juce::AudioBuffer block(2, 64); block.clear(); juce::MidiBuffer events; @@ -56820,6 +57019,45 @@ juce::var AudioEngine::runNAMRackRegression() object->setProperty( "stopbandRejectionPassed", result.stopbandRejectionPassed); + object->setProperty( + "excitationMetadataPassed", + result.excitationMetadataPassed); + auto* lifecycle = new juce::DynamicObject(); + lifecycle->setProperty("pass", result.lifecycle.passed); + lifecycle->setProperty("retirementPartitionPassed", result.lifecycle.retirementPartitionPassed); + lifecycle->setProperty("unaffectedVoicePassed", result.lifecycle.unaffectedVoicePassed); + lifecycle->setProperty("retiredProfileRecallPassed", result.lifecycle.retiredProfileRecallPassed); + lifecycle->setProperty("fuzzPairPartitionPassed", result.lifecycle.fuzzPairPartitionPassed); + lifecycle->setProperty("retirementComparisons", result.lifecycle.retirementComparisons); + lifecycle->setProperty("profileComparisons", result.lifecycle.profileComparisons); + lifecycle->setProperty("fuzzPairComparisons", result.lifecycle.fuzzPairComparisons); + lifecycle->setProperty("maximumRetirementDifference", result.lifecycle.maximumRetirementDifference); + lifecycle->setProperty("maximumMetadataDifference", result.lifecycle.maximumMetadataDifference); + lifecycle->setProperty("maximumUnaffectedVoiceDifference", result.lifecycle.maximumUnaffectedVoiceDifference); + lifecycle->setProperty("maximumRetiredProfileDifference", result.lifecycle.maximumRetiredProfileDifference); + lifecycle->setProperty("maximumFuzzPairDifference", result.lifecycle.maximumFuzzPairDifference); + object->setProperty("lifecycle", juce::var(lifecycle)); + object->setProperty( + "excitationCoefficientBudgetPassed", + result.excitationCoefficientBudgetPassed); + object->setProperty( + "excitationFastPathsPassed", + result.excitationFastPathsPassed); + object->setProperty( + "maximumExcitationCoefficientError", + result.maximumExcitationCoefficientError); + object->setProperty( + "maximumExcitationFloatCoefficientDifference", + result.maximumExcitationFloatCoefficientDifference); + object->setProperty( + "maximumExcitationBudgetExcessEpsilons", + result.maximumExcitationBudgetExcessEpsilons); + object->setProperty( + "maximumExcitationMetadataPartitionDifference", + result.maximumExcitationMetadataPartitionDifference); + object->setProperty( + "maximumExcitationAudioDifference", + result.maximumExcitationAudioDifference); object->setProperty( "maximumResetDifference", result.maximumResetDifference); @@ -56870,9 +57108,13 @@ juce::var AudioEngine::runNAMRackRegression() checks, "stereo_poly_octaver_multirate_contract", polyOctaverSelfTestsPassed ? "pass" : "fail", - "The V8 Stereo Poly Octaver must preserve exact bypass/silence, deterministic reset and arbitrary callback partitions, independent L/R state, finite recovery, Guitar octave-target dominance, Bass B0/E1 response, bounded partition-invariant live profile switching, and at least 70 dB stop-band rejection at 44.1/48/96 kHz. Subjective chord/transient quality and pedal similarity remain not asserted.", + "The V8 Stereo Poly Octaver must preserve exact bypass/silence, deterministic reset and arbitrary callback partitions, independent L/R state, finite recovery, Guitar octave-target dominance, Bass B0/E1 response, bounded partition-invariant live profile switching, exact same-frame excitation coefficients with a two-float-epsilon budget, unchanged audio with optional metadata, and at least 70 dB stop-band rejection at 44.1/48/96 kHz. Subjective chord/transient quality and pedal similarity remain not asserted.", juce::var(polyOctaverSelfTests)); + const auto fuzzRegressionChecks = NAMFuzzRegression::run(*this); + for (const auto& fuzzCheck : fuzzRegressionChecks) + checks.add(fuzzCheck); + auto runNAMRackMonoInputStereoOutputProbe = [] () { constexpr double sampleRate = 48000.0; @@ -64175,6 +64417,20 @@ static juce::var describePitchVisualization(OpenStudioPitchCorrector* pitch) return juce::var(viz.get()); } +static std::atomic* freeSignalPath(juce::AudioProcessor* processor) +{ + if (auto* p = dynamic_cast(processor)) return p->supportsExternalKey() ? &p->signalPath : nullptr; + if (auto* p = dynamic_cast(processor)) return &p->signalPath; + if (auto* p = dynamic_cast(processor)) return &p->signalPath; + if (auto* p = dynamic_cast(processor)) return &p->signalPath; + if (auto* p = dynamic_cast(processor)) return &p->signalPath; + if (auto* p = dynamic_cast(processor)) return &p->signalPath; + if (auto* p = dynamic_cast(processor)) return &p->signalPath; + if (auto* p = dynamic_cast(processor)) return &p->signalPath; + if (auto* p = dynamic_cast(processor)) return p->kind == OpenStudioUtilityEffect::Kind::Preamp ? &p->values[15] : nullptr; + return nullptr; +} + static juce::var describeBuiltInProcessorUnchecked(juce::AudioProcessor* processor, const juce::String& chainType, int fxIndex) @@ -64995,6 +65251,12 @@ static juce::var describeBuiltInProcessorUnchecked(juce::AudioProcessor* process addToggleParam(params,"nonlinearHold","Hold",reverb->nonlinearHold.load(),0,"hold"); addToggleParam(params,"positionedHold","Hold",reverb->positionedHold.load(),0,"hold"); addToggleParam(params,"reconstructedPeaks","8x peak estimate",reverb->reconstructedPeaks.load(),0,"metering"); + addContinuousParam(params,"outputTrimDb","Output",reverb->outputTrimDb.load(),-60,12,0,"dB","output"); + addEnumParam(params,"reverbCharacterMode","Processing",reverb->reverbCharacterMode.load(),0,makeEnumOptions({"Original","Refined"}),"character"); + addEnumParam(params,"reverbReturnMode","Return level",reverb->reverbReturnMode.load(),1,makeEnumOptions({"Original","Calibrated"}),"character"); + addEnumParam(params,"shimmerScheduling","Pitch scheduling",reverb->shimmerScheduling.load(),0,makeEnumOptions({"Original","Spread"}),"character"); + addContinuousParam(params,"ambBody","Broadband body",reverb->ambBody.load(),0,1,0,{},"character"); + addEnumParam(params,"reverbControlMode","Controls",reverb->reverbControlMode.load(),1,makeEnumOptions({"Legacy","Corrected"}),"character"); } auto result = makeBuiltInSchemaObject(reverb->getName(), "Reverb", chainType, fxIndex, params); if (reverb->standaloneBanking) result.getDynamicObject()->setProperty("impulseResponse", reverb->convolutionSpace.info()); @@ -65092,6 +65354,12 @@ static juce::var describeBuiltInProcessorUnchecked(juce::AudioProcessor* process addContinuousParam(params, "octaverDownMix", "Oct -1", rack->octaverDownMix.load(), 0.0f, 1.0f, 0.32f, {}, "pitch"); addContinuousParam(params, "octaverUpMix", "Oct +1", rack->octaverUpMix.load(), 0.0f, 1.0f, 0.18f, {}, "pitch"); addContinuousParam(params, "octaverDirectMix", "Direct", rack->octaverDirectMix.load(), 0.0f, 1.25f, 1.0f, {}, "pitch"); + addToggleParam(params, "fuzzEnabled", "Fuzz", rack->fuzzEnabled.load(), 0.0f, "pitch"); + addContinuousParam(params, "fuzzDrive", "Fuzz", rack->fuzzDrive.load(), 0.0f, 1.0f, 0.5f, {}, "pitch"); + addContinuousParam(params, "fuzzTone", "Fuzz Tone", rack->fuzzTone.load(), 0.0f, 1.0f, 0.5f, {}, "pitch"); + addContinuousParam(params, "fuzzLevelDb", "Fuzz Level", rack->fuzzLevelDb.load(), -18.0f, 12.0f, 0.0f, "dB", "pitch"); + addEnumParam(params, "fuzzVoice", "Fuzz Voice", rack->fuzzVoice.load(), 0.0f, makeEnumOptions({ "Vintage", "Sustain", "Gated" }), "pitch"); + addContinuousParam(params, "fuzzCleanMix", "Fuzz Clean Blend", rack->fuzzCleanMix.load(), 0.0f, 1.0f, 0.0f, {}, "pitch"); const bool bassPreEqProfile = rack->getInstrumentProfile() == OpenStudioNAMRack::bassInstrumentProfile; @@ -65438,6 +65706,18 @@ juce::var describeFreePluginForRegression(juce::AudioProcessor& processor) static bool setBuiltInProcessorParam(juce::AudioProcessor* processor, const juce::String& paramId, float value) { + if (paramId == "signalPath") + { + if (!std::isfinite(value)) return false; + auto* signal = freeSignalPath(processor); if (!signal) return false; + signal->store(value >= .5f ? 1.0f : 0.0f); + // Configuration changes occur under the host publication lock, never + // as sample automation. Reprepare bounded voice/filter state off-callback. + if (processor->getSampleRate() > 0 && processor->getBlockSize() > 0) + processor->prepareToPlay(processor->getSampleRate(), processor->getBlockSize()); + return true; + } + if(paramId=="externalDetector")if(auto* eq=dynamic_cast(processor);eq&&eq->hasPreparedMIDIPrograms())return false; if (paramId == "externalDetector") if (auto* effect = dynamic_cast(processor); effect != nullptr && effect->supportsExternalKey()) @@ -65916,6 +66196,12 @@ static bool setBuiltInProcessorParam(juce::AudioProcessor* processor, const juce } if (paramId == "algorithm" || ((paramId == "reverbType" || paramId == "reverbTypeExpanded" || paramId == "reverbTypeExtended" || paramId == "reverbTypeAll") && reverb->standaloneBanking)) { reverb->selectAlgorithm(static_cast(std::round(value))); return true; } if (paramId == "sendMode" && reverb->standaloneBanking) { reverb->selectSendMode(value >= 0.5f); return true; } + if (paramId == "outputTrimDb" && reverb->standaloneBanking) return storeClamped(reverb->outputTrimDb, value, -60.0f, 12.0f); + if (paramId == "reverbCharacterMode" && reverb->standaloneBanking) return storeClamped(reverb->reverbCharacterMode, std::round(value), 0.0f, 1.0f); + if (paramId == "reverbReturnMode" && reverb->standaloneBanking) return storeClamped(reverb->reverbReturnMode, std::round(value), 0.0f, 1.0f); + if (paramId == "shimmerScheduling" && reverb->standaloneBanking) return storeClamped(reverb->shimmerScheduling, std::round(value), 0.0f, 1.0f); + if (paramId == "ambBody" && reverb->standaloneBanking) return storeClamped(reverb->ambBody, value, 0.0f, 1.0f); + if (paramId == "reverbControlMode" && reverb->standaloneBanking) return storeClamped(reverb->reverbControlMode, std::round(value), 0.0f, 1.0f); if (paramId == "mixLock") return storeClamped(reverb->mixLock, value, 0.0f, 1.0f); if (paramId == "insertWet") return storeClamped(reverb->insertWet, value, 0.0f, 1.0f); if (paramId == "insertDry") return storeClamped(reverb->insertDry, value, 0.0f, 1.0f); @@ -66024,6 +66310,12 @@ static bool setBuiltInProcessorParam(juce::AudioProcessor* processor, const juce if (paramId == "octaverDownMix") return storeClamped(rack->octaverDownMix, value, 0.0f, 1.0f); if (paramId == "octaverUpMix") return storeClamped(rack->octaverUpMix, value, 0.0f, 1.0f); if (paramId == "octaverDirectMix") return storeClamped(rack->octaverDirectMix, value, 0.0f, 1.25f); + if (paramId == "fuzzEnabled") return storeClamped(rack->fuzzEnabled, std::round(value), 0.0f, 1.0f); + if (paramId == "fuzzDrive") return storeClamped(rack->fuzzDrive, value, 0.0f, 1.0f); + if (paramId == "fuzzTone") return storeClamped(rack->fuzzTone, value, 0.0f, 1.0f); + if (paramId == "fuzzLevelDb") return storeClamped(rack->fuzzLevelDb, value, -18.0f, 12.0f); + if (paramId == "fuzzVoice") return storeClamped(rack->fuzzVoice, std::round(value), 0.0f, 2.0f); + if (paramId == "fuzzCleanMix") return storeClamped(rack->fuzzCleanMix, value, 0.0f, 1.0f); if (paramId == "preEqEnabled") return storeClamped(rack->preEqEnabled, value, 0.0f, 1.0f); if (paramId == "preEq120Db") return storeClamped(rack->preEq120Db, value, -12.0f, 12.0f); if (paramId == "preEq250Db") return storeClamped(rack->preEq250Db, value, -12.0f, 12.0f); @@ -66285,7 +66577,8 @@ static bool requiresLockedBuiltInControlMutation( // state, and updates reported latency. Keep it on the explicit locked/PDC // path; every other exposed control in the processors above is a scalar // publication consumed and smoothed by processBlock(). - return (dynamic_cast(processor) != nullptr && (parameterId == "predelayCapacity" || parameterId == "spatialDelayCapacity" || (parameterId.startsWith("spatial")&¶meterId.endsWith(".delayCapacity")) || parameterId.startsWith("modal"))) + return parameterId == "signalPath" + || (dynamic_cast(processor) != nullptr && (parameterId == "predelayCapacity" || parameterId == "spatialDelayCapacity" || (parameterId.startsWith("spatial")&¶meterId.endsWith(".delayCapacity")) || parameterId.startsWith("modal"))) || (dynamic_cast(processor) != nullptr && (parameterId == "spectralPhaseEnabled" || parameterId == "audioCharacter")) || (dynamic_cast(processor) != nullptr && parameterId == "oversampleMode") || (dynamic_cast(processor) != nullptr && parameterId == "oversampleQuality") @@ -67319,6 +67612,21 @@ juce::var AudioEngine::getBuiltInFXPresets(const juce::String& pluginName) } } + if (safePluginName == "OpenStudio Reverb") + { + for (const auto& voice : OpenStudioReverbFactoryPresets::voices) + { + // Existing disk entries retain priority, including a historical + // user preset that happens to share a new factory display name. + if (seenNames.contains(juce::String(voice.name).toLowerCase())) + continue; + auto* preset = new juce::DynamicObject(); + preset->setProperty("name", voice.name); + preset->setProperty("factory", true); + preset->setProperty("metadata", OpenStudioReverbFactoryPresets::metadata(voice)); + presetList.add(juce::var(preset)); + } + } return presetList; } @@ -67362,6 +67670,12 @@ bool AudioEngine::saveBuiltInFXPreset(const juce::String& trackId, const juce::S if (processor == nullptr) return false; + if (OpenStudioReverbFactoryPresets::isFactory(processor->getName(), safePresetName) + && ! findBuiltInPresetFile(processor->getName(), safePresetName).existsAsFile()) + { + return false; // Generated factory entries are immutable. + } + juce::MemoryBlock stateData; if (auto* rack = dynamic_cast(processor)) @@ -67432,12 +67746,19 @@ bool AudioEngine::loadBuiltInFXPreset(const juce::String& trackId, const juce::S return false; const auto file = findBuiltInPresetFile(processor->getName(), safePresetName); - if (!file.existsAsFile()) - return false; - juce::MemoryBlock stateData; - if (!file.loadFileAsData(stateData)) + const bool generatedFactory = ! file.existsAsFile() + && OpenStudioReverbFactoryPresets::isFactory(processor->getName(), safePresetName); + if (file.existsAsFile() ? ! file.loadFileAsData(stateData) + : ! generatedFactory || ! OpenStudioReverbFactoryPresets::buildState(safePresetName, stateData)) return false; + if (generatedFactory) + { + auto* reverb = dynamic_cast(processor); + if (reverb == nullptr || ! reverb->standaloneBanking) + return false; + OpenStudioReverbFactoryPresets::preserveLockedMix(*reverb, stateData); + } int latencyBefore = 0; int latencyAfter = 0; @@ -67530,8 +67851,13 @@ juce::String AudioEngine::getBuiltInFXPresetData(const juce::String& pluginName, const auto file = findBuiltInPresetFile(safePluginName, safePresetName); juce::MemoryBlock storedPayload; - if (! file.existsAsFile() || ! file.loadFileAsData(storedPayload) - || storedPayload.getSize() == 0) + if (file.existsAsFile()) + { + if (! file.loadFileAsData(storedPayload) || storedPayload.getSize() == 0) + return {}; + } + else if (! OpenStudioReverbFactoryPresets::isFactory(safePluginName, safePresetName) + || ! OpenStudioReverbFactoryPresets::buildState(safePresetName, storedPayload)) { return {}; } @@ -67563,7 +67889,8 @@ bool AudioEngine::saveBuiltInFXPresetData(const juce::String& pluginName, return false; } - if (findBuiltInPresetFile(safePluginName, safePresetName).existsAsFile()) + if (findBuiltInPresetFile(safePluginName, safePresetName).existsAsFile() + || OpenStudioReverbFactoryPresets::isFactory(safePluginName, safePresetName)) return false; juce::MemoryBlock storedPayload; @@ -67611,19 +67938,22 @@ bool AudioEngine::copyBuiltInFXPreset(const juce::String& pluginName, const auto safePluginName = pluginName.trim(); const auto sourceFile = findBuiltInPresetFile(safePluginName, sourceName); - if (! sourceFile.existsAsFile()) + const bool generatedFactory = ! sourceFile.existsAsFile() + && OpenStudioReverbFactoryPresets::isFactory(safePluginName, sourceName); + if (! sourceFile.existsAsFile() && ! generatedFactory) return false; // Duplicate/Rename must never silently replace an existing library entry, // including the alternate legacy extension. - if (findBuiltInPresetFile(safePluginName, targetName).existsAsFile()) + if (findBuiltInPresetFile(safePluginName, targetName).existsAsFile() + || OpenStudioReverbFactoryPresets::isFactory(safePluginName, targetName)) return false; const auto dir = getPersistentBuiltInPresetsDir(safePluginName); if (! dir.createDirectory()) return false; const auto targetFile = dir.getChildFile( targetName - + (safePluginName == "OpenStudio NAM Rack" + + (safePluginName == "OpenStudio NAM Rack" || generatedFactory ? ".ospreset" : sourceFile.getFileExtension())); @@ -67631,7 +67961,12 @@ bool AudioEngine::copyBuiltInFXPreset(const juce::String& pluginName, // so Duplicate/Rename cannot momentarily recall a different live tone. juce::MemoryBlock storedPayload; juce::File metadataSource = sourceFile; - if (safePluginName == "OpenStudio NAM Rack") + if (generatedFactory) + { + if (! OpenStudioReverbFactoryPresets::buildState(sourceName, storedPayload)) + return false; + } + else if (safePluginName == "OpenStudio NAM Rack") { NAMRackPresetFileMigration migration; if (! migrateNAMRackPresetFileToCurrent( @@ -67651,6 +67986,26 @@ bool AudioEngine::copyBuiltInFXPreset(const juce::String& pluginName, if (! writeBuiltInPresetPayloadAtomically(targetFile, storedPayload)) return false; + if (generatedFactory) + { + const auto* voice = OpenStudioReverbFactoryPresets::find(sourceName); + if (voice == nullptr) + { + (void) targetFile.deleteFile(); + return false; + } + const auto json = juce::JSON::toString(OpenStudioReverbFactoryPresets::metadata(*voice)); + const juce::MemoryBlock storedMetadata(json.toRawUTF8(), json.getNumBytesAsUTF8()); + const auto targetMetadata = getBuiltInPresetMetadataFile(targetFile); + if (! writeBuiltInPresetPayloadAtomically(targetMetadata, storedMetadata)) + { + (void) targetFile.deleteFile(); + (void) targetMetadata.deleteFile(); + return false; + } + return true; + } + const auto sourceMetadata = getBuiltInPresetMetadataFile(metadataSource); if (sourceMetadata.existsAsFile()) { @@ -67668,6 +68023,367 @@ bool AudioEngine::copyBuiltInFXPreset(const juce::String& pluginName, return true; } +namespace +{ +bool isSafeNAMProfileCopyName(const juce::String& name) +{ + if (! isSafeBuiltInPresetName(name) || name.endsWithChar('.')) + return false; + for (const auto character : name) + if (character < 32 || character == 127) + return false; + + // Reject Windows device aliases on every platform so copied presets remain portable. + const auto stem = name.upToFirstOccurrenceOf(".", false, false).toUpperCase(); + if (stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL") + return false; + if (stem.length() == 4 && (stem.startsWith("COM") || stem.startsWith("LPT"))) + return ! ((stem[3] >= '1' && stem[3] <= '9') + || stem[3] == 0x00b9 || stem[3] == 0x00b2 || stem[3] == 0x00b3); + return true; +} + +bool namProfileCopyTargetExists(const juce::String& targetName) +{ + const auto payloadName = targetName + ".ospreset"; + const auto metadataName = payloadName + ".metadata.json"; + for (const auto& directory : getBuiltInPresetDirectoriesStableFirst("OpenStudio NAM Rack")) + for (const auto& file : directory.findChildFiles(juce::File::findFilesAndDirectories, false)) + if (file.getFileName().equalsIgnoreCase(payloadName) + || file.getFileName().equalsIgnoreCase(metadataName)) + return true; + return false; +} + +std::filesystem::path namPresetFilesystemPath(const juce::File& file) +{ + return std::filesystem::u8path(file.getFullPathName().toRawUTF8()); +} + +bool publishNewNAMPresetFile(const juce::File& stagingFile, const juce::File& targetFile) +{ + // A same-directory hard link atomically publishes complete bytes without replacing + // an existing file. Unsupported filesystems fail closed; no destructive fallback. + std::error_code error; + std::filesystem::create_hard_link( + namPresetFilesystemPath(stagingFile), namPresetFilesystemPath(targetFile), error); + return ! error; +} + +void rollbackNewNAMPresetFile(const juce::File& stagingFile, const juce::File& targetFile) +{ + // Retain the staging link until commit so rollback cannot remove an orphan sidecar + // or a file replaced by another writer after publication. + std::error_code error; + if (std::filesystem::equivalent( + namPresetFilesystemPath(stagingFile), namPresetFilesystemPath(targetFile), error) + && ! error) + (void) targetFile.deleteFile(); +} + +bool copyNAMRackPresetWithInstrumentProfileImpl( + const juce::String& sourcePresetName, + const juce::String& targetPresetName, + int instrumentProfile, + const std::function& beforePayloadPublication = {}) +{ + // This is a message/control-thread library operation, never an audio callback. + static juce::CriticalSection copyLock; + const juce::ScopedLock lock(copyLock); + const auto sourceName = sourcePresetName.trim(); + const auto targetName = targetPresetName.trim(); + if (! isSafeBuiltInPresetName(sourceName) || ! isSafeNAMProfileCopyName(targetName) + || (instrumentProfile != OpenStudioNAMRack::guitarInstrumentProfile + && instrumentProfile != OpenStudioNAMRack::bassInstrumentProfile) + || namProfileCopyTargetExists(targetName)) + return false; + + const auto sourceFile = findBuiltInPresetFile("OpenStudio NAM Rack", sourceName); + juce::MemoryBlock payload; + bool migrated = false; + if (! sourceFile.existsAsFile() || ! sourceFile.loadFileAsData(payload) + || payload.getSize() == 0 + || ! OpenStudioNAMRack::migratePresetStateToCurrent(payload, migrated)) + return false; + + auto state = juce::ValueTree::readFromData(payload.getData(), payload.getSize()); + if (! state.hasType("OpenStudioNAMRack")) + return false; + // Preserve the normal migration and all tone/resource/UI properties. The user's + // explicit profile choice is the sole change to the resulting canonical state. + state.setProperty("instrumentProfile", instrumentProfile, nullptr); + payload.reset(); + { + juce::MemoryOutputStream stream(payload, false); + state.writeToStream(stream); + } + + const auto sourceMetadataFile = getBuiltInPresetMetadataFile(sourceFile); + juce::var sourceMetadata; + if (sourceMetadataFile.exists()) + { + if (! sourceMetadataFile.existsAsFile()) + return false; + sourceMetadata = juce::JSON::parse(sourceMetadataFile); + const auto* object = sourceMetadata.getDynamicObject(); + if (object == nullptr + || (object->hasProperty("kind") + && object->getProperty("kind").toString().isNotEmpty() + && object->getProperty("kind").toString() != "openstudio.namRackPresetMetadata") + || (object->hasProperty("metadata") + && object->getProperty("metadata").getDynamicObject() == nullptr)) + return false; + } + + juce::var metadata; + juce::var envelope; + if (sourceMetadata.getDynamicObject() != nullptr && sourceMetadata.hasProperty("metadata")) + { + envelope = sourceMetadata; + metadata = sourceMetadata.getProperty("metadata", {}); + } + else + { + // Older sidecars store the metadata object directly rather than in an envelope. + metadata = sourceMetadata.getDynamicObject() != nullptr + ? sourceMetadata : juce::var(new juce::DynamicObject()); + envelope = juce::var(new juce::DynamicObject()); + envelope.getDynamicObject()->setProperty("schemaVersion", 1); + envelope.getDynamicObject()->setProperty("kind", "openstudio.namRackPresetMetadata"); + envelope.getDynamicObject()->setProperty("pluginName", "OpenStudio NAM Rack"); + } + metadata.getDynamicObject()->setProperty("instrumentProfile", + instrumentProfile == OpenStudioNAMRack::bassInstrumentProfile ? "bass" : "guitar"); + envelope.getDynamicObject()->setProperty("presetName", targetName); + envelope.getDynamicObject()->setProperty("metadata", metadata); + const auto metadataJSON = juce::JSON::toString(envelope); + + const auto directory = getPersistentBuiltInPresetsDir("OpenStudio NAM Rack"); + if (! directory.createDirectory()) + return false; + const auto targetFile = directory.getChildFile(targetName + ".ospreset"); + const auto targetMetadataFile = getBuiltInPresetMetadataFile(targetFile); + juce::TemporaryFile stagedPayload(targetFile, juce::TemporaryFile::useHiddenFile); + juce::TemporaryFile stagedMetadata(targetMetadataFile, juce::TemporaryFile::useHiddenFile); + if (! stagedPayload.getFile().replaceWithData(payload.getData(), payload.getSize()) + || ! stagedMetadata.getFile().replaceWithData( + metadataJSON.toRawUTF8(), metadataJSON.getNumBytesAsUTF8()) + || namProfileCopyTargetExists(targetName) + || ! publishNewNAMPresetFile(stagedMetadata.getFile(), targetMetadataFile)) + return false; + + // The payload is the library's commit marker: listing never sees half-written data + // or a new preset without its sidecar. The hook is only used by isolated regressions. + if (beforePayloadPublication) + beforePayloadPublication(targetFile); + if (! publishNewNAMPresetFile(stagedPayload.getFile(), targetFile)) + { + rollbackNewNAMPresetFile(stagedMetadata.getFile(), targetMetadataFile); + return false; + } + return true; +} +} + +bool AudioEngine::copyNAMRackPresetWithInstrumentProfile( + const juce::String& sourcePresetName, + const juce::String& targetPresetName, + int instrumentProfile) +{ + return copyNAMRackPresetWithInstrumentProfileImpl( + sourcePresetName, targetPresetName, instrumentProfile); +} + +juce::var AudioEngine::runNAMRackPresetProfileCopyRegression() +{ + ScopedBuiltInPresetTestDirectories isolatedPresetStorage; + juce::ignoreUnused(isolatedPresetStorage); + juce::Array checks; + bool passed = true; + const auto check = [&] (const juce::String& id, bool success, const juce::String& detail) + { + juce::DynamicObject::Ptr item = new juce::DynamicObject(); + item->setProperty("id", id); + item->setProperty("status", success ? "pass" : "fail"); + item->setProperty("detail", detail); + checks.add(juce::var(item.get())); + passed = passed && success; + }; + const auto serialize = [] (const juce::ValueTree& tree) + { + juce::MemoryBlock bytes; + { + juce::MemoryOutputStream stream(bytes, false); + tree.writeToStream(stream); + } + return bytes; + }; + const auto read = [] (const juce::File& file) + { + juce::MemoryBlock bytes; + (void) file.loadFileAsData(bytes); + return bytes; + }; + const auto directory = getPersistentBuiltInPresetsDir("OpenStudio NAM Rack"); + const auto legacyDirectory = getLegacyBuiltInPresetsDir("OpenStudio NAM Rack"); + const auto payloadFile = [&] (const juce::String& name) + { + return directory.getChildFile(name + ".ospreset"); + }; + const auto metadataFile = [&] (const juce::String& name) + { + return getBuiltInPresetMetadataFile(payloadFile(name)); + }; + + juce::ValueTree legacyState("OpenStudioNAMRack"); + legacyState.setProperty("namEffectsDspVersion", 2, nullptr); + legacyState.setProperty("ampModelPath", "preserved/model.nam", nullptr); + legacyState.setProperty("cabIRPath", "preserved/cab.wav", nullptr); + legacyState.setProperty("drive", 0.554, nullptr); + legacyState.setProperty("unknownFutureField", "preserve this", nullptr); + legacyState.setProperty("uiStateJSON", "{\"customView\":\"preserved\"}", nullptr); + juce::ValueTree child("PreservedChild"); + child.setProperty("value", 37, nullptr); + legacyState.addChild(child, -1, nullptr); + const auto sourceBytes = serialize(legacyState); + const auto sourceFile = legacyDirectory.getChildFile("Legacy Bass.ospreset"); + const auto sourceMetadata = getBuiltInPresetMetadataFile(sourceFile); + const juce::String sourceJSON( + "{\"schemaVersion\":1,\"kind\":\"openstudio.namRackPresetMetadata\"," + "\"pluginName\":\"OpenStudio NAM Rack\",\"presetName\":\"Legacy Bass\"," + "\"extensionField\":{\"preserve\":true},\"metadata\":{\"instrumentProfile\":\"bass\"," + "\"notes\":\"Keep these notes\",\"folder\":\"Recovered\",\"tags\":[\"bass\",\"recovered\"]}}"); + const bool fixturesWritten = directory.createDirectory().wasOk() + && legacyDirectory.createDirectory().wasOk() + && sourceFile.replaceWithData(sourceBytes.getData(), sourceBytes.getSize()) + && sourceMetadata.replaceWithText(sourceJSON); + check("profile_copy_fixture_setup", fixturesWritten, "Only temporary fixture directories are used."); + + auto canonicalBytes = sourceBytes; + bool migrated = false; + const bool canonicalized = OpenStudioNAMRack::migratePresetStateToCurrent(canonicalBytes, migrated); + auto expectedState = juce::ValueTree::readFromData(canonicalBytes.getData(), canonicalBytes.getSize()); + expectedState.setProperty("instrumentProfile", OpenStudioNAMRack::bassInstrumentProfile, nullptr); + const bool copied = fixturesWritten + && copyNAMRackPresetWithInstrumentProfile("Legacy Bass", "Bass corrected", 1); + const auto copiedBytes = read(payloadFile("Bass corrected")); + const auto copiedState = juce::ValueTree::readFromData(copiedBytes.getData(), copiedBytes.getSize()); + check("profile_copy_legacy_migration_and_profile_only", copied && canonicalized && migrated + && expectedState.isEquivalentTo(copiedState), + "Copy matches normal in-memory migration with only canonical instrumentProfile changed; all other properties and children match."); + check("profile_copy_source_bytes_untouched", read(sourceFile) == sourceBytes + && sourceMetadata.loadFileAsString() == sourceJSON + && ! payloadFile("Legacy Bass").exists(), + "The original legacy payload and sidecar remain byte-identical and are not silently migrated into persistent storage."); + + const auto copiedMetadata = juce::JSON::parse(metadataFile("Bass corrected")); + auto expectedMetadata = juce::JSON::parse(sourceJSON); + expectedMetadata.getDynamicObject()->setProperty("presetName", "Bass corrected"); + check("profile_copy_metadata_preserved", copied + && juce::JSON::toString(copiedMetadata) == juce::JSON::toString(expectedMetadata), + "Notes, folders, tags, unknown fields and the chosen profile survive in the new sidecar."); + const auto firstCopyMetadataBytes = read(metadataFile("Bass corrected")); + check("profile_copy_duplicate_case_rejected", + ! copyNAMRackPresetWithInstrumentProfile("Legacy Bass", "BASS CORRECTED", 0) + && read(payloadFile("Bass corrected")) == copiedBytes + && read(metadataFile("Bass corrected")) == firstCopyMetadataBytes, + "A case-insensitive existing entry is never overwritten."); + const bool reverseCopied = copyNAMRackPresetWithInstrumentProfile("Bass corrected", "Guitar corrected", 0); + const auto reverseBytes = read(payloadFile("Guitar corrected")); + check("profile_copy_current_reverse_profile", reverseCopied + && OpenStudioNAMRack::getTonePresetInstrumentProfile( + reverseBytes.getData(), static_cast(reverseBytes.getSize())) == 0 + && juce::JSON::parse(metadataFile("Guitar corrected")).getProperty("metadata", {}) + .getProperty("instrumentProfile", {}).toString() == "guitar", + "Current-state copies can explicitly choose Guitar and synchronize sidecar metadata."); + + const juce::String orphanText("Keep this pre-existing orphan sidecar"); + const bool orphanCreated = metadataFile("Orphan").replaceWithText(orphanText); + check("profile_copy_orphan_sidecar_rejected", orphanCreated + && ! copyNAMRackPresetWithInstrumentProfile("Legacy Bass", "ORPHAN", 1) + && metadataFile("Orphan").loadFileAsString() == orphanText + && ! payloadFile("Orphan").exists(), + "A pre-existing orphan sidecar is preserved, including case-insensitive collisions."); + + bool invalidRejected = ! copyNAMRackPresetWithInstrumentProfile("Legacy Bass", "Invalid profile", 2) + && ! copyNAMRackPresetWithInstrumentProfile("Legacy Bass", "Invalid profile", -1) + && ! copyNAMRackPresetWithInstrumentProfile("Missing source", "Missing copy", 1) + && ! copyNAMRackPresetWithInstrumentProfile("Legacy Bass", "legacy bass", 1); + for (const auto* invalidName : { "", "..", "../escape", "a\\b", "bad:name", "trailing.", "CON", "com1.test", "LPT9" }) + invalidRejected = ! copyNAMRackPresetWithInstrumentProfile("Legacy Bass", invalidName, 1) + && invalidRejected; + check("profile_copy_invalid_requests_rejected", invalidRejected, + "Missing sources, invalid profiles, unsafe paths/device names and existing legacy names are rejected."); + + const bool badPayloadCreated = payloadFile("Malformed").replaceWithText("not a NAM preset"); + const bool badMetadataCreated = metadataFile("Bass corrected").replaceWithText("malformed JSON"); + check("profile_copy_malformed_inputs_rejected", badPayloadCreated && badMetadataCreated + && ! copyNAMRackPresetWithInstrumentProfile("Malformed", "Bad payload copy", 1) + && ! copyNAMRackPresetWithInstrumentProfile("Bass corrected", "Bad metadata copy", 1) + && ! payloadFile("Bad payload copy").exists() && ! payloadFile("Bad metadata copy").exists(), + "Corrupt state and sidecars fail without publishing an incomplete copy."); + (void) metadataFile("Bass corrected").replaceWithData( + firstCopyMetadataBytes.getData(), firstCopyMetadataBytes.getSize()); + + const bool flatSourceWritten = payloadFile("Flat metadata").replaceWithData(sourceBytes.getData(), sourceBytes.getSize()) + && metadataFile("Flat metadata").replaceWithText("{\"notes\":\"flat preserved\",\"unknown\":42}"); + const bool flatCopied = flatSourceWritten + && copyNAMRackPresetWithInstrumentProfile("Flat metadata", "Flat copied", 1); + const auto flatMetadata = juce::JSON::parse(metadataFile("Flat copied")).getProperty("metadata", {}); + check("profile_copy_flat_metadata_supported", flatCopied + && flatMetadata.getProperty("notes", {}).toString() == "flat preserved" + && static_cast(flatMetadata.getProperty("unknown", 0)) == 42 + && flatMetadata.getProperty("instrumentProfile", {}).toString() == "bass", + "Legacy flat metadata is retained in a readable sidecar envelope."); + const bool bareSourceWritten = payloadFile("No metadata").replaceWithData(sourceBytes.getData(), sourceBytes.getSize()); + check("profile_copy_missing_metadata_created", bareSourceWritten + && copyNAMRackPresetWithInstrumentProfile("No metadata", "Bare copied", 1) + && juce::JSON::parse(metadataFile("Bare copied")).getProperty("metadata", {}) + .getProperty("instrumentProfile", {}).toString() == "bass", + "Presets without sidecars gain explicit profile metadata in the new copy."); + + const juce::String concurrentPayload("concurrent payload must survive"); + const bool failedCommit = copyNAMRackPresetWithInstrumentProfileImpl( + "Legacy Bass", "Commit collision", 1, [&] (const juce::File& target) + { + (void) target.replaceWithText(concurrentPayload); + }); + check("profile_copy_failed_commit_rolls_back_owned_metadata", ! failedCommit + && payloadFile("Commit collision").loadFileAsString() == concurrentPayload + && ! metadataFile("Commit collision").exists(), + "A payload publication collision preserves the new competing payload and removes only our staged sidecar."); + const juce::String concurrentMetadata("concurrent metadata must survive"); + const bool replacedCommit = copyNAMRackPresetWithInstrumentProfileImpl( + "Legacy Bass", "Replaced sidecar", 1, [&] (const juce::File& target) + { + const auto targetMetadata = getBuiltInPresetMetadataFile(target); + (void) targetMetadata.deleteFile(); + (void) targetMetadata.replaceWithText(concurrentMetadata); + (void) target.replaceWithText(concurrentPayload); + }); + check("profile_copy_rollback_preserves_replacement", ! replacedCommit + && metadataFile("Replaced sidecar").loadFileAsString() == concurrentMetadata + && payloadFile("Replaced sidecar").loadFileAsString() == concurrentPayload, + "Rollback recognizes that a competing writer replaced the sidecar and preserves that replacement."); + + bool listed = false; + const auto library = getBuiltInFXPresets("OpenStudio NAM Rack"); + if (const auto* entries = library.getArray()) + for (const auto& entry : *entries) + if (entry.getProperty("name", {}).toString() == "Bass corrected") + listed = entry.getProperty("instrumentProfile", {}).toString() == "bass" + && entry.getProperty("metadata", {}).getProperty("metadata", {}) + .getProperty("instrumentProfile", {}).toString() == "bass"; + check("profile_copy_library_refresh_matches_payload", listed, + "Library refresh exposes matching canonical and sidecar profiles for the copied entry."); + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", passed); + result->setProperty("checks", checks); + result->setProperty("scope", "Isolated temporary preset files; no processor, user preset, device or audio callback is touched."); + return juce::var(result.get()); +} + bool AudioEngine::deleteBuiltInFXPreset(const juce::String& pluginName, const juce::String& presetName) { const auto safePluginName = pluginName.trim(); @@ -67886,6 +68602,15 @@ static juce::var describeBuiltInProcessor(juce::AudioProcessor* processor, int fxIndex) { auto schema = describeBuiltInProcessorUnchecked(processor, chainType, fxIndex); + if (auto* signal = freeSignalPath(processor)) + if (auto* parameters = schema["parameters"].getArray()) + { + for (int i = parameters->size(); --i >= 0;) + if ((*parameters)[i]["id"].toString() == "signalPath") parameters->remove(i); + addEnumParam(*parameters, "signalPath", "Signal path", signal->load(), 1, + makeEnumOptions({"Legacy", "Corrected"}), "configuration"); + parameters->getLast().getDynamicObject()->setProperty("automatable", false); + } if (auto* parameters = schema["parameters"].getArray()) for (auto& item : *parameters) if (auto* parameter = item.getDynamicObject()) @@ -69062,6 +69787,15 @@ bool AudioEngine::setBuiltInPluginState(const juce::String& trackId, const juce: } } + // Named numeric snapshots predate the signal-path selector. A values-only + // edit is intentionally partial and must not change compatibility mode. + const auto migrationOwner = getPublishedBuiltInProcessor(trackId, chainType, fxIndex); + const auto* migrationProcessor = migrationOwner.processor.get(); + if (migrationProcessor && stateObject->getProperty("name").toString() == migrationProcessor->getName() + && freeSignalPath(migrationOwner.processor.get()) && !parameterValues.empty() + && std::none_of(parameterValues.begin(), parameterValues.end(), [](const auto& entry) { return entry.first == "signalPath"; })) + parameterValues.emplace_back("signalPath", 0.0f); + if (completePortableNAMBundle) { const auto findParameter = [&] (const juce::String& id) @@ -69764,6 +70498,7 @@ bool AudioEngine::removeTrackInputFX(const juce::String& trackId, int fxIndex) // the snapshot is replaced, then release the retained processor only // after it can no longer be processed. const juce::ScopedLock audioCallbackGuard (deviceManager.getAudioCallbackLock()); + drainRealtimeTrackExecution(); { const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); @@ -69813,6 +70548,7 @@ bool AudioEngine::removeTrackFX(const juce::String& trackId, int fxIndex) bool removed = false; { const juce::ScopedLock audioCallbackGuard (deviceManager.getAudioCallbackLock()); + drainRealtimeTrackExecution(); { const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); @@ -70040,6 +70776,7 @@ bool AudioEngine::removeMasterFX(int fxIndex) // the swap; only then is releaseResources() lifetime-safe. { const juce::ScopedLock audioCallbackGuard (deviceManager.getAudioCallbackLock()); + drainRealtimeTrackExecution(); } if (retiringProcessor) { @@ -70365,6 +71102,7 @@ void AudioEngine::removeMonitoringFX(int fxIndex) { { const juce::ScopedLock audioCallbackGuard (deviceManager.getAudioCallbackLock()); + drainRealtimeTrackExecution(); } if (retiringProcessor) { @@ -71516,6 +72254,7 @@ juce::var AudioEngine::getMidiDiagnostics() const juce::var AudioEngine::getRealtimeAudioTelemetry() const { auto* root = new juce::DynamicObject(); + root->setProperty("trackExecution", getRealtimeTrackExecutionDiagnostics()); root->setProperty("telemetryMode", "compact-lock-free"); root->setProperty( "transportPlaying", @@ -71917,6 +72656,7 @@ juce::var AudioEngine::getAudioDebugSnapshot() const root->setProperty("transportPlaying", isPlaying.load()); root->setProperty("transportRecording", isRecordMode.load()); root->setProperty("offlineRenderActive", isRendering.load()); + root->setProperty("trackExecution", getRealtimeTrackExecutionDiagnostics()); root->setProperty("transportPosition", getTransportPosition()); root->setProperty("sampleRate", currentSampleRate); root->setProperty("blockSize", currentBlockSize); @@ -73186,7 +73926,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do if (wasPlaying) isPlaying = false; if (wasRecording) isRecordMode = false; const ScopedAudioCallbackProcessingPause realtimeProcessingPause( - isRendering, deviceManager); + isRendering, deviceManager, &realtimeTrackExecutor); // Restore transport state before the processing-pause guard below resumes // the device callback, including every early-return path. struct ScopedRenderTransportMode @@ -75089,7 +75829,7 @@ bool AudioEngine::renderProjectWithDither(const juce::String& source, double sta includedClipIds); } -juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirectory) +juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirectory, bool requireOffline) { juce::Array checks; juce::Array artifacts; @@ -75111,6 +75851,35 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto overallPass = overallPass && pass; }; + const auto hardwareEvidence = [this]() + { + auto* evidence = new juce::DynamicObject(); + evidence->setProperty("startupMode", deviceStartupMode == DeviceStartup::offline ? "offline" : "openHardware"); + evidence->setProperty("audioDeviceOpened", deviceManager.getCurrentAudioDevice() != nullptr); + evidence->setProperty("audioDeviceStartCount", static_cast(audioDeviceStartCount.load(std::memory_order_relaxed))); + evidence->setProperty("audioCallbackCount", static_cast(audioCallbackCounter.load(std::memory_order_relaxed))); + evidence->setProperty("callbackRegistrationCount", static_cast(hardwareCallbackRegistrationCount.load(std::memory_order_relaxed))); + return juce::var(evidence); + }; + const auto isOfflineEvidence = [](const juce::var& evidence) + { + return evidence["startupMode"].toString() == "offline" && !static_cast(evidence["audioDeviceOpened"]) + && static_cast(evidence["audioDeviceStartCount"]) == 0 + && static_cast(evidence["audioCallbackCount"]) == 0 + && static_cast(evidence["callbackRegistrationCount"]) == 0; + }; + const auto hardwareBefore = hardwareEvidence(); + if (requireOffline && !isOfflineEvidence(hardwareBefore)) + { + addCheck("offline_launch_contract", false, "Headless export QA refuses hardware startup, device opens or callback registration.", hardwareBefore); + auto* failed = new juce::DynamicObject(); + failed->setProperty("harnessMode", "render_export_regression"); + failed->setProperty("objectiveGateStatus", "fail"); + failed->setProperty("checks", checks); failed->setProperty("hardwareBefore", hardwareBefore); + failed->setProperty("subjectiveQuality", "not_asserted"); + return juce::var(failed); + } + const bool outputDirectoryReady = outputDirectory.createDirectory() || outputDirectory.isDirectory(); addCheck("output_directory_ready", @@ -75290,7 +76059,7 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto "Cancel aborts every armed recorder and leaves plugin state/history untouched."); isPlaying.store(playingBefore);isLooping.store(loopBefore);isRendering.store(renderBefore);currentSamplePosition.store(positionBefore); currentSampleRate=rateBefore; - deviceManager.addAudioCallback(this); + registerHardwareAudioCallback(); } removeTrack(alignTrackB);auto* removedRequest=new juce::DynamicObject();removedRequest->setProperty("entries",applied["after"]);const auto removed=gainPhaseAlignment("apply",juce::var(removedRequest)); addCheck("alignment_removed_member_rejects_whole_group",!static_cast(removed["success"])&&static_cast(getBuiltInPluginState(alignTrackA,"track",0)["values"]["delayL"])==0,"A removed member cannot redirect an old group transaction or partially change surviving members."); @@ -76566,6 +77335,34 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto } } addCheck("vintage_internal_rate_export_parity",tankPass&&tankError<2e-6&&tankChange>1e-6,"Prepared low-rate tanks render consistently in master, stem and selected-item exports.",tankError); + juce::AudioBuffer reverbOutputMaster, reverbOutputStem, reverbOutputSelected; + bool reverbOutputExport = tankPass && setBuiltInPluginParam(phaseTrack, "track", 1, "outputTrimDb", 6) + && renderPhaseCase("master", "reverb-output-master", reverbOutputMaster) + && renderPhaseCase("stem:" + phaseTrack, "reverb-output-stem", reverbOutputStem) + && renderPhaseCase("selected_items", "reverb-output-selected", reverbOutputSelected); + double reverbOutputError = 0; + reverbOutputExport = reverbOutputExport && reverbOutputMaster.getNumSamples() == tankStem.getNumSamples() + && reverbOutputStem.getNumSamples() == tankStem.getNumSamples() + && reverbOutputSelected.getNumSamples() == tankStem.getNumSamples(); + if (reverbOutputExport) for (int ch = 0; ch < 2; ++ch) for (int i = 0; i < tankStem.getNumSamples(); ++i) + { + const double scaledWet = tankStem.getSample(ch, i) * juce::Decibels::decibelsToGain(6.0f); + reverbOutputError = std::max({reverbOutputError, + std::abs(reverbOutputStem.getSample(ch, i) - scaledWet), + std::abs(reverbOutputSelected.getSample(ch, i) - scaledWet), + std::abs(reverbOutputMaster.getSample(ch, i) - phaseBaseline.getSample(ch, i) * .5 - scaledWet)}); + } + addCheck("reverb_output_gain_export", reverbOutputExport && reverbOutputError < 2e-6, + "Final +6 dB gain scales actual stem, selected-item and master exports consistently.", reverbOutputError); + const auto reverbOutputSaved = getBuiltInPluginState(phaseTrack, "track", 1); + const bool reverbOutputRecall = rateReverb && setBuiltInPluginParam(phaseTrack, "track", 1, "outputTrimDb", -6) + && setBuiltInPluginState(phaseTrack, "track", 1, juce::JSON::toString(reverbOutputSaved)) + && rateReverb->outputTrimDb.load() == 6 + && setBuiltInPluginState(phaseTrack, "track", 1, "{\"factoryDefault\":true}") + && rateReverb->outputTrimDb.load() == 0 + && setBuiltInPluginState(phaseTrack, "track", 1, juce::JSON::toString(tankState)); + addCheck("reverb_output_gain_host_recall", reverbOutputRecall, + "Complete host state recalls final output gain and factory reset restores unity."); bool tankRecall=rateReverb&&setBuiltInPluginParam(phaseTrack,"track",1,"vintageTankRate",2) &&setBuiltInPluginState(phaseTrack,"track",1,juce::JSON::toString(tankState))&&rateReverb->vintageTankRate[0].load()==1; tankRecall=setBuiltInPluginState(phaseTrack,"track",1,"{\"factoryDefault\":true}")&&tankRecall @@ -76997,7 +77794,7 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto bool macroFactory=setBuiltInPluginState(macroTrack,"track",0,"{\"factoryDefault\":true}"); if(macroSynth)for(const auto& control:macroSynth->macroMappings)macroFactory=macroFactory&&(macroSynth->*control.member).load()==0;else macroFactory=false; addCheck("macro_cc_host_factory",macroFactory,"Factory reset disables all controller mappings and restores Any-channel defaults."); - removeTrack(macroTrack);deviceManager.addAudioCallback(this); + removeTrack(macroTrack);registerHardwareAudioCallback(); const bool diffusionMounted = removeTrackFX(phaseTrack, 1) && addTrackBuiltInFX(phaseTrack, "OpenStudio Delay", false); bool diffusionReady = diffusionMounted && setBuiltInPluginParam(phaseTrack,"track",1,"delayType",4) && setBuiltInPluginParam(phaseTrack,"track",1,"delayTimeL",31) && setBuiltInPluginParam(phaseTrack,"track",1,"delayTimeR",43) @@ -77018,6 +77815,7 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto #include "AudioEngineHostBypassRegression.inc" #include "AudioEngineAnalogCharacterRegression.inc" +#include "ReverbFactoryLibraryRegression.inc" #include "AudioEnginePresetFileRegression.inc" #include "AudioEngineOutputRoutingRegression.inc" #include "AudioEngineInstrumentTailRegression.inc" @@ -77154,6 +77952,13 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto } auto* root = new juce::DynamicObject(); + const auto hardwareAfter = hardwareEvidence(); + if (requireOffline) + addCheck("offline_launch_contract", isOfflineEvidence(hardwareBefore) && isOfflineEvidence(hardwareAfter), + "Explicit offline startup retained zero device starts, hardware callbacks and callback registrations for the complete export fixture.", hardwareAfter); + root->setProperty("hardwareBefore", hardwareBefore); + root->setProperty("hardwareAfter", hardwareAfter); + root->setProperty("offlineRequired", requireOffline); root->setProperty("harnessMode", "render_export_regression"); root->setProperty("claimLevel", "objective_only"); root->setProperty("objectiveGateStatus", overallPass ? "pass" : "fail"); @@ -77909,7 +78714,7 @@ juce::var AudioEngine::freezeTrack(const juce::String& trackId) const bool freezeWasPlaying = isPlaying.exchange(false, std::memory_order_acq_rel); const bool freezeWasRecording = isRecordMode.exchange(false, std::memory_order_acq_rel); const ScopedAudioCallbackProcessingPause realtimeProcessingPause( - isRendering, deviceManager); + isRendering, deviceManager, &realtimeTrackExecutor); struct ScopedFreezeTransportMode { std::atomic& playing; @@ -79372,6 +80177,7 @@ juce::var AudioEngine::getPitchCorrectorData(const juce::String& trackId, int fx for (int i = 0; i < 12; ++i) noteEnables.add(mapper.isNoteEnabled(i)); obj->setProperty("noteEnables", noteEnables); + obj->setProperty("signalPath", pc->signalPath.load()); return juce::var(obj.get()); } @@ -82300,6 +83106,7 @@ juce::var AudioEngine::shutdownARAForTrack(const juce::String& trackId) // control-owned unique_ptr. const juce::ScopedLock audioCallbackGuard( deviceManager.getAudioCallbackLock()); + drainRealtimeTrackExecution(); it->second->shutdownARA(); obj->setProperty("success", true); return juce::var(obj.release()); @@ -82316,6 +83123,7 @@ bool AudioEngine::isARAActiveForTrack(const juce::String& trackId) const #include "AudioEngineStageAutomation.inc" #include "AudioEngineAutomationPreview.inc" #include "AudioEngineAutomationPreviewRegression.inc" +#include "AudioEngineParallelRegression.inc" bool AudioEngine::setAutomationTrimValue(const juce::String& id, float db, const juce::String& parameterId) { diff --git a/Source/AudioEngine.h b/Source/AudioEngine.h index 8623385..a90e05f 100644 --- a/Source/AudioEngine.h +++ b/Source/AudioEngine.h @@ -1,5 +1,6 @@ #include "BuiltInIRPreparation.h" #pragma once +#include "RealtimeTrackExecutor.h" #include #include "TrackProcessor.h" @@ -42,7 +43,8 @@ class AudioEngine : public juce::AudioIODeviceCallback, struct ActiveFXStage; public: - AudioEngine(); + enum class DeviceStartup { openHardware, offline }; + explicit AudioEngine(DeviceStartup startup = DeviceStartup::openHardware); ~AudioEngine() override; void reportStageProcessorFaults(); // Control thread; only called after fault/reset generation changes. @@ -73,6 +75,14 @@ class AudioEngine : public juce::AudioIODeviceCallback, bool hybrid64PostChainActive); juce::AudioDeviceManager& getDeviceManager() { return deviceManager; } + // Control-thread diagnostics; -1 uses physical cores, zero forces serial. + bool setRealtimeTrackWorkerCount(int workers); + bool setRealtimeTrackWorkerSpinMicroseconds(int microseconds); + bool setProbeTrackRecoveryGraceMicroseconds(int microseconds); + bool setProbeNAMRackOversamplingFactor(const juce::String& trackId, int factor); + juce::var getRealtimeTrackExecutionDiagnostics() const; + bool setProbeRecordingDirectory(const juce::File& directory); + static juce::var runParallelTrackRegression(); // Messaging juce::String addTrack(const juce::String& explicitId = juce::String(), @@ -186,6 +196,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, // Get clips that were completed in the last recording session std::vector getLastCompletedClips(); juce::var takeRecordingWriteFailures() { return audioRecorder.takeWriteFailures(); } + void maintainRecordingPreviews() { audioRecorder.maintainRecordingPreviews(); } // Get MIDI clips that were completed in the last recording session std::vector getLastCompletedMIDIClips(); @@ -251,6 +262,11 @@ class AudioEngine : public juce::AudioIODeviceCallback, bool copyBuiltInFXPreset(const juce::String& pluginName, const juce::String& sourcePresetName, const juce::String& targetPresetName); + // Library-only copy: never recalls a preset or rewrites its source. + bool copyNAMRackPresetWithInstrumentProfile(const juce::String& sourcePresetName, + const juce::String& targetPresetName, + int instrumentProfile); + juce::var runNAMRackPresetProfileCopyRegression(); bool deleteBuiltInFXPreset(const juce::String& pluginName, const juce::String& presetName); // Plugin Editor Windows (Phase 3) - ID-based @@ -302,7 +318,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, bool setMonitoringFXPrecisionOverride(int fxIndex, const juce::String& mode); juce::var runReleaseGuardrails(); juce::var runAutomatedRegressionSuite(); - juce::var runRenderExportRegression(const juce::File& outputDirectory); + juce::var runRenderExportRegression(const juce::File& outputDirectory, bool requireOffline = false); juce::var runCleanGuitarPitchBendRegression(); juce::var runNAMRackRegression(); juce::var runNAMRackDIRegression(const juce::File& inputFile, @@ -728,6 +744,19 @@ class AudioEngine : public juce::AudioIODeviceCallback, std::shared_ptr> destinationBuffer; }; + struct RealtimeTrackWork + { + juce::AudioBuffer buffer; + juce::MidiBuffer midi; + int capacity = 0; + int jobIndex = -1; + TrackProcessor* processor = nullptr; + uint64 graphGeneration = 0, automationEpoch = 0; + double position = 0.0, rate = 44100.0; + bool playing = false, excludedBySolo = false; + bool shouldReadHardwareInput = false, hasActiveARA = false; + bool prepared = false, processed = false; + }; struct RealtimeTrackEntry { juce::String id; @@ -741,6 +770,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, sidechainSourceBuffers; std::vector sends; bool hasIncomingSends = false; + std::shared_ptr work; }; struct DesiredFXStageSlot @@ -872,7 +902,13 @@ class AudioEngine : public juce::AudioIODeviceCallback, std::vector tracks; std::vector processingOrder; // Sized during control-thread publication, never in the callback. int processingOrderCount = 0; + std::unique_ptr jobs; }; + static void runParallelTrackJob(void* context) noexcept; + void drainRealtimeTrackExecution(); // Caller has stopped callback dispatch. + void recordQuarantinedTrackInput(const RealtimeTrackEntry& entry, + const float* const* inputs, int inputCount, + int samples, juce::int64 blockPosition); struct PublishedBuiltInProcessorOwner { std::shared_ptr processor; @@ -1016,6 +1052,14 @@ class AudioEngine : public juce::AudioIODeviceCallback, std::atomic realtimeMonitoringFXSnapshotForAudio { nullptr }; std::atomic realtimeSnapshotAudioReaders { 0 }; + RealtimeTrackExecutor realtimeTrackExecutor; + int requestedRealtimeWorkers = -1; + std::atomic probeTrackRecoveryGraceMicroseconds { 0 }; + const RealtimeTrackSnapshot* pendingParallelSnapshot = nullptr; + std::atomic parallelTrackBlocks { 0 }, parallelTrackRejectedJobs { 0 }; + std::atomic parallelQuarantinedBlocks { 0 }; + std::atomic parallelLastFailureCallback { 0 }; + juce::File probeRecordingDirectory; juce::CriticalSection realtimeSnapshotRetirementLock; std::vector> retiredRealtimeSnapshotOwners; @@ -1052,6 +1096,9 @@ class AudioEngine : public juce::AudioIODeviceCallback, std::atomic lastAudioCallbackProcessMs { 0.0 }; std::atomic maxAudioCallbackProcessMs { 0.0 }; std::atomic audioCallbackCounter { 0 }; + const DeviceStartup deviceStartupMode; + std::atomic hardwareCallbackRegistrationCount { 0 }; + void registerHardwareAudioCallback(); std::atomic audioCallbackDeadlineMissCount { 0 }; std::atomic lifetimeAudioCallbackDeadlineMissCount { 0 }; std::atomic lastAudioCallbackDeadlineMissCounter { 0 }; @@ -1293,7 +1340,6 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::AudioBuffer reusableStageFXDryBuffer; juce::AudioBuffer reusableStageFXDryBufferDouble; juce::AudioBuffer reusablePitchScrubBuffer; - juce::MidiBuffer reusableRealtimeMidiBuffer; juce::MidiBuffer reusableMasterMidiBuffer, reusableMonitoringMidiBuffer; float stageFXBypassRampStep = 1.0f / 882.0f; int stageFXContinuityRampSamples = 353; diff --git a/Source/AudioEngineInstrumentTailRegression.inc b/Source/AudioEngineInstrumentTailRegression.inc index 57907ab..3fff432 100644 --- a/Source/AudioEngineInstrumentTailRegression.inc +++ b/Source/AudioEngineInstrumentTailRegression.inc @@ -36,3 +36,46 @@ "The native export planner reserves an automation-safe release, releases hold pedals at content end, and reaches independent -120 dBFS silence before the end of the exported file.", finalPeak); removeTrack(source); } + + for (int engine = 0; engine < 2; ++engine) + { + const auto source = addTrack("drum-tail-" + juce::String(engine), "midi"); + bool ready = addTrackBuiltInFX(source, "OpenStudio Drums", false); + setTrackVolume(source, 0); + ready = setBuiltInPluginParam(source, "track", 0, "articulationEngine", static_cast(engine)) && ready; + setTrackMIDIClips(source, R"json([{"id":"crash-tail","startTime":0,"duration":0.25,"events":[{"type":"noteOn","timestamp":0,"note":49,"velocity":127,"channel":10}]}])json"); + const juce::String automation = "builtin_track_0_pieceDecay6"; + setAutomationPoints(source, automation, R"([{"time":0,"value":1},{"time":0.25,"value":1}])"); + setAutomationMode(source, automation, "read"); + const double tail = trackMap[source]->getOfflineRenderTailLengthSeconds(); + const auto file = outputDirectory.getChildFile("drum-host-tail-" + juce::String(engine) + ".wav"); + juce::AudioBuffer audio; double rate = 0; + ready = renderProject("stem:" + source, 0, .25, file.getFullPathName(), "wav", 48000, 32, 2, false, true, 0, false) + && readAudioFileForParity(file, audio, rate) && ready; + const int endWindow = juce::roundToInt(rate * .5); + const bool length = rate > 0 && audio.getNumSamples() >= juce::roundToInt((tail + .25) * rate) - 1; + const float finalPeak = endWindow > 0 && audio.getNumSamples() >= endWindow ? audio.getMagnitude(audio.getNumSamples() - endWindow, endWindow) : 1; + const float latePeak = audio.getNumSamples() > 96000 ? audio.getMagnitude(24000, 72000) : 0; + addCheck("drum_host_release_tail_" + juce::String(engine), ready && length && tail >= 68 && finalPeak < 1e-6f && latePeak > 1e-5f, + "Automatic native export preserves the crash after CC123, reserves the automated decay maximum, and finishes below -120 dBFS.", finalPeak); + removeTrack(source); + } + + for (const auto* name : {"OpenStudio EQ", "OpenStudio Preamp", "OpenStudio Saturator", "OpenStudio Chorus", "OpenStudio Pitch Correct", "OpenStudio Basic Synth", "OpenStudio Piano", "OpenStudio Clean Guitar", "OpenStudio Drums"}) + { + const auto source = addTrack("signal-state-" + juce::String(name), "audio"); + bool ready = addTrackBuiltInFX(source, name, false); + const auto modern = getBuiltInPluginState(source, "track", 0); + juce::MemoryBlock bytes; ready = bytes.fromBase64Encoding(modern["fullState"].toString()) && ready; + auto tree = juce::ValueTree::readFromData(bytes.getData(), bytes.getSize()); tree.removeProperty("signalPath", nullptr); + juce::MemoryBlock old; { juce::MemoryOutputStream stream(old, false); tree.writeToStream(stream); } + juce::DynamicObject::Ptr legacy = new juce::DynamicObject(); legacy->setProperty("name", name); legacy->setProperty("fullState", old.toBase64Encoding()); + const auto selected = [&] { return static_cast(getBuiltInPluginState(source,"track",0)["values"]["signalPath"]); }; + ready = ready && selected() == 1 && setBuiltInPluginState(source,"track",0,juce::JSON::toString(juce::var(legacy.get()))) && selected() == 0; + ready = setBuiltInPluginState(source,"track",0,juce::JSON::toString(modern)) && selected() == 1 && ready; + auto numeric = modern.clone(); numeric.getDynamicObject()->removeProperty("fullState"); numeric["values"].getDynamicObject()->removeProperty("signalPath"); + ready = setBuiltInPluginState(source,"track",0,juce::JSON::toString(numeric)) && selected() == 0 && ready; + ready = setBuiltInPluginState(source,"track",0,"{\"factoryDefault\":true}") && selected() == 1 && ready; + addCheck("signal_path_host_recall_" + juce::String(name), ready, "Native named numeric snapshots and full-state recalls migrate old settings to Legacy; factory and current complete state select Corrected."); + removeTrack(source); + } diff --git a/Source/AudioEngineParallelRegression.inc b/Source/AudioEngineParallelRegression.inc new file mode 100644 index 0000000..d642073 --- /dev/null +++ b/Source/AudioEngineParallelRegression.inc @@ -0,0 +1,900 @@ +// Included by AudioEngine.cpp so the fixture can inspect the host's private +// ownership and scratch-capacity invariants without adding production APIs. +#include "AudioDeviceConfigurationRegression.h" + +juce::var AudioEngine::runParallelTrackRegression() +{ + class FixtureDevice final : public juce::AudioIODevice + { + public: + FixtureDevice() : AudioIODevice("Parallel track fixture", "Offline fixture") {} + juce::StringArray getOutputChannelNames() override { return { "Left", "Right" }; } + juce::StringArray getInputChannelNames() override + { + juce::StringArray names; + for (int channel = 0; channel < 12; ++channel) names.add("Input " + juce::String(channel + 1)); + return names; + } + juce::Array getAvailableSampleRates() override { return { 48000.0 }; } + juce::Array getAvailableBufferSizes() override { return { 512 }; } + int getDefaultBufferSize() override { return 512; } + juce::String open(const juce::BigInteger&, const juce::BigInteger&, double, int) override + { return "Offline fixture cannot open hardware"; } + void close() override {} + bool isOpen() override { return false; } + void start(juce::AudioIODeviceCallback*) override {} + void stop() override {} + bool isPlaying() override { return false; } + juce::String getLastError() override { return {}; } + int getCurrentBufferSizeSamples() override { return 512; } + double getCurrentSampleRate() override { return 48000.0; } + int getCurrentBitDepth() override { return 32; } + juce::BigInteger getActiveOutputChannels() const override { return juce::BigInteger(3); } + juce::BigInteger getActiveInputChannels() const override { return juce::BigInteger(4095); } + int getOutputLatencyInSamples() override { return 0; } + int getInputLatencyInSamples() override { return 0; } + } device; + + juce::Array checks, configurations; + bool passed = true; + const auto check = [&](const juce::String& name, bool success, const char* detail, + const juce::var& diagnostics = juce::var()) + { + auto* result = new juce::DynamicObject(); + result->setProperty("name", name); + result->setProperty("pass", success); + result->setProperty("detail", detail); + if (!diagnostics.isVoid()) result->setProperty("diagnostics", diagnostics); + checks.add(result); + passed = passed && success; + }; + { + // Use the real query and response serialization with a simulated driver. + // An equal cloned current object must not detach later draft edits from + // the returned response, and discovery must never apply those edits. + auto engine = std::make_unique(DeviceStartup::offline); + const juce::String oversamplingTrack = "settings-probe-oversampling"; + bool probeFactorValid = engine->addTrack(oversamplingTrack, "audio") == oversamplingTrack + && engine->addTrackBuiltInFX(oversamplingTrack, "OpenStudio NAM Rack", false); + for (const int factor : { 2, 8, 4 }) + probeFactorValid = engine->setProbeNAMRackOversamplingFactor(oversamplingTrack, factor) && probeFactorValid; + for (const int factor : { 0, 1, 3, 16 }) + probeFactorValid = !engine->setProbeNAMRackOversamplingFactor(oversamplingTrack, factor) && probeFactorValid; + const auto probeState = engine->getNAMRackDiagnostics(oversamplingTrack, "track", 0); + check("audio_probe_oversampling_validation", probeFactorValid + && static_cast(probeState.getProperty("embeddedDriveRequestedOversamplingFactor", 0)) == 4 + && engine->getNAMRackOversamplingFactor() == 4, + "Offline per-rack requests accept 2/4/8, reject unsupported factors and leave the global preference unchanged."); + auto& manager = engine->deviceManager; + juce::Array originalTypes; + for (auto* type : manager.getAvailableDeviceTypes()) originalTypes.add(type); + for (auto* type : originalTypes) manager.removeAudioDeviceType(type); + const juce::String fixtureType = "Settings query fixture"; + manager.addAudioDeviceType(std::make_unique( + fixtureType, false, AudioDeviceConfigurationRegression::OpenMode::normal, false)); + juce::AudioDeviceManager::AudioDeviceSetup initial; + initial.inputDeviceName = initial.outputDeviceName = "Interface"; + initial.sampleRate = 44100.0; + initial.bufferSize = 8; + const auto openError = AudioDeviceConfiguration::apply(manager, fixtureType, initial, + false, true, [](auto& setup) { + setup.inputChannels.setRange(0, 2, true); + setup.outputChannels.setRange(0, 2, true); + setup.useDefaultInputChannels = setup.useDefaultOutputChannels = false; + }); + auto* const opened = manager.getCurrentAudioDevice(); + const bool ready = openError.isEmpty() && opened != nullptr; + check("audio_settings_query_fixture", ready, + "Only a simulated coupled driver opens at 44100 Hz / 8 samples; physical device types are removed."); + check("audio_probe_oversampling_rejects_open_device", ready + && !engine->setProbeNAMRackOversamplingFactor(oversamplingTrack, 8), + "A per-rack diagnostic oversampling request is refused while any device is open."); + if (ready) + { + engine->refreshAudioDeviceSetupSnapshot(opened); + const auto applied = engine->getAudioDeviceSetup(); + const auto query = [&](double rate, int block) { + auto request = applied.getProperty("current", {}).clone(); + request.getDynamicObject()->setProperty("sampleRate", rate); + request.getDynamicObject()->setProperty("bufferSize", block); + return juce::JSON::parse(juce::JSON::toString(engine->queryAudioDeviceSetup(request))); + }; + for (const int block : { 16, 32, 64, 128 }) + { + const auto response = query(44100.0, block); + const auto current = response.getProperty("current", {}); + check("audio_settings_query_buffer_" + juce::String(block), + response.getProperty("error", {}).toString().isEmpty() + && static_cast(current.getProperty("bufferSize", 0)) == block + && static_cast(current.getProperty("sampleRate", 0)) == 44100.0, + "The serialized native response retains the requested supported buffer before Apply.", response); + } + const auto rateResponse = query(48000.0, 8); + check("audio_settings_query_sample_rate", + rateResponse.getProperty("error", {}).toString().isEmpty() + && static_cast(rateResponse.getProperty("current", {}).getProperty("sampleRate", 0)) == 48000.0 + && static_cast(rateResponse.getProperty("current", {}).getProperty("bufferSize", 0)) == 8, + "A same-device sample-rate draft is also published after resolution.", rateResponse); + const auto afterQueries = engine->getAudioDeviceSetup(); + check("audio_settings_query_preserves_applied_setup", + afterQueries.getProperty("current", {}) == applied.getProperty("current", {}) + && manager.getCurrentAudioDevice() == opened + && opened->getCurrentBufferSizeSamples() == 8 && opened->getCurrentSampleRate() == 44100.0 + && engine->hardwareCallbackRegistrationCount.load() == 0, + "Draft discovery leaves the cached applied setup and simulated running device unchanged."); + + auto unavailable = applied.clone(); + unavailable.getDynamicObject()->setProperty("bufferSizes", juce::Array()); + { + const juce::ScopedLock lock(engine->audioDeviceSetupSnapshotLock); + engine->audioDeviceSetupSnapshot = std::move(unavailable); + } + const auto failedResponse = query(48000.0, 64); + check("audio_settings_failed_query_publishes_resolved_draft", + failedResponse.getProperty("error", {}).toString().isNotEmpty() + && failedResponse.getProperty("capabilityStatus", {}).toString() == "unavailable" + && static_cast(failedResponse.getProperty("current", {}).getProperty("sampleRate", 0)) == 48000.0 + && static_cast(failedResponse.getProperty("current", {}).getProperty("bufferSize", -1)) == 0 + && opened->getCurrentBufferSizeSamples() == 8 && opened->getCurrentSampleRate() == 44100.0, + "A failed capability query serializes its final draft and leaves the active device untouched.", failedResponse); + } + } + const auto trackId = [](int index) { return "parallel-fixture-" + juce::String(index); }; + const auto createEngine = [&](int trackCount, int workerCount) + { + auto engine = std::make_unique(DeviceStartup::offline); + engine->requestedRealtimeWorkers = workerCount; + engine->audioDeviceAboutToStart(&device); + bool ready = engine->realtimeTrackExecutor.getWorkerCount() == workerCount; + for (int index = 0; index < trackCount; ++index) + { + const auto id = trackId(index); + const bool added = engine->addTrack(id, "audio") == id + && engine->addTrackBuiltInFX(id, "OpenStudio NAM Rack", false); + ready = added && ready; + if (!added) continue; + engine->setTrackInputChannels(id, index, 1); + engine->setTrackRecordArm(id, true); + engine->setTrackInputMonitoring(id, true); + engine->setTrackVolume(id, -18.0f); + auto* rack = dynamic_cast(engine->trackMap.at(id)->getTrackFXProcessor(0)); + ready = rack != nullptr && ready; + if (rack != nullptr) + { + // Exercise stateful native processing without loading any user + // model, IR or preset. This is not model-capacity evidence. + rack->ampEnabled.store(0.0f); + rack->cabEnabled.store(0.0f); + rack->precisionDriveEnabled.store(1.0f); + rack->precisionDriveVolumeDb.store(0.0f); + rack->precisionDriveDrive.store(0.2f + 0.025f * static_cast(index)); + } + } + check("fixture_" + juce::String(trackCount) + "_tracks_" + juce::String(workerCount) + "_workers", + ready && engine->getDeviceManager().getCurrentAudioDevice() == nullptr, + "Real host callback and native NAM processors; the fake device cannot open hardware."); + return engine; + }; + constexpr int capacity = 512; + constexpr float poison = 0.9375f; + juce::AudioBuffer input(12, capacity), serialOutput(2, capacity), parallelOutput(2, capacity); + const auto fillInput = [&](int blockIndex) + { + for (int channel = 0; channel < input.getNumChannels(); ++channel) + for (int sample = 0; sample < capacity; ++sample) + input.setSample(channel, sample, static_cast(0.1 * std::sin( + 0.017 * (sample + blockIndex * capacity) + 0.29 * channel))); + }; + const auto callback = [&](AudioEngine& engine, juce::AudioBuffer& output, int samples) + { + for (int channel = 0; channel < output.getNumChannels(); ++channel) + juce::FloatVectorOperations::fill(output.getWritePointer(channel), poison, capacity); + engine.audioDeviceIOCallbackWithContext(input.getArrayOfReadPointers(), input.getNumChannels(), + output.getArrayOfWritePointers(), output.getNumChannels(), samples, {}); + // Control-side inspection must never race a job which missed its + // deadline. Production quarantine is checked by the executor tests. + engine.drainRealtimeTrackExecution(); + }; + const auto exactOutput = [&](int samples) + { + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < samples; ++sample) + if (!std::isfinite(serialOutput.getSample(channel, sample)) + || serialOutput.getSample(channel, sample) != parallelOutput.getSample(channel, sample)) return false; + return true; + }; + + for (const int trackCount : { 4, 12 }) + { + auto serial = createEngine(trackCount, 0); + auto parallel = createEngine(trackCount, trackCount - 1); + bool exact = true, stateChanges = true, finite = true, suffixPreserved = true; + float peak = 0.0f; + for (int block = 0; block < 40; ++block) + { + for (auto* engine : { serial.get(), parallel.get() }) + { + if (block == 4) + { + engine->setTrackVolume(trackId(0), -12.0f); + engine->setTrackPan(trackId(1), -0.35f); + engine->setTrackInputChannels(trackId(2), 0, 1); + } + if (block == 8) + { + engine->setTrackMute(trackId(1), true); + engine->setTrackSolo(trackId(0), true); + } + if (block == 12) + { + engine->setTrackMute(trackId(1), false); + engine->setTrackSolo(trackId(0), false); + engine->setTrackMasterSendEnabled(trackId(trackCount - 1), false); + engine->setTrackOutputChannels(trackId(1), 1, 1); + } + if (block == 16) + { + engine->setTrackMasterSendEnabled(trackId(trackCount - 1), true); + engine->setTrackOutputChannels(trackId(1), 0, 2); + engine->setTrackInputChannels(trackId(0), 0, 2); + } + if (block == 20) + { + auto* rack = dynamic_cast(engine->trackMap.at(trackId(0))->getTrackFXProcessor(0)); + stateChanges = rack != nullptr && stateChanges; + if (rack != nullptr) rack->precisionDriveDrive.store(0.73f); + } + if (block == 24) engine->trackMap.at(trackId(1))->bypassTrackFX(0, true); + if (block == 28) engine->trackMap.at(trackId(1))->bypassTrackFX(0, false); + if (block == 32) stateChanges = engine->reorderTrack(trackId(0), trackCount - 1) && stateChanges; + if (block == 36) stateChanges = engine->removeTrack(trackId(trackCount - 1)) && stateChanges; + } + fillInput(block); + callback(*serial, serialOutput, capacity); + callback(*parallel, parallelOutput, capacity); + exact = exactOutput(capacity) && exact; + peak = juce::jmax(peak, parallelOutput.getMagnitude(0, capacity)); + } + const auto parityStats = parallel->realtimeTrackExecutor.snapshotStats(); + check("serial_parallel_exact_" + juce::String(trackCount), exact && stateChanges && peak > 0.000001f + && parityStats.batches == 40 && parityStats.completedBatches == 40 + && parityStats.workerJobs > 0 && parityStats.cancelledJobs == 0 + && parallel->parallelTrackRejectedJobs.load() == 0, + "Forty 512-frame blocks: mono/stereo input, volume/pan, mute/solo, output route, master send, live Drive, bypass, reorder and removal preserve exact serial output."); + + // Small callbacks exercise allocation capacity and buffer bounds. They + // deliberately retain their real deadline; do not turn a scheduler + // timing result on this test runner into an audio-quality assertion. + const auto* snapshot = parallel->realtimeTrackSnapshotForAudio.load(); + std::vector scratchBases; + for (const auto& entry : snapshot->tracks) scratchBases.push_back(entry.work->buffer.getReadPointer(0)); + bool allocationStable = true; + for (int repeat = 0; repeat < 8; ++repeat) + for (const int length : { 1, 8, 16, 7, 31, 128, 512 }) + { + fillInput(40 + repeat); + callback(*parallel, parallelOutput, length); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < capacity; ++sample) + { + const float value = parallelOutput.getSample(channel, sample); + finite = std::isfinite(value) && finite; + if (sample >= length) suffixPreserved = value == poison && suffixPreserved; + } + for (size_t index = 0; index < snapshot->tracks.size(); ++index) + allocationStable = snapshot->tracks[index].work->buffer.getReadPointer(0) == scratchBases[index] + && allocationStable; + } + check("variable_callback_bounds_" + juce::String(trackCount), finite && suffixPreserved && allocationStable + && parallel->audioCallbackTrackBufferResizeCount.load() == 0 && parallel->oversizedAudioCallbackCount.load() == 0, + "1/7/8/16/31/128/512 active samples remain finite, preserve the output suffix and retain every preallocated track-audio base address; this is not a general heap-allocation assertion."); + + // Connected/non-NAM tracks stay serial while any remaining isolated + // NAM pair retains its worker batch. + const auto mixedBlock = [&](int expectedIndependent) + { + const auto before = parallel->realtimeTrackExecutor.snapshotStats().batches; + fillInput(100); + callback(*parallel, parallelOutput, capacity); + return parallel->realtimeTrackExecutor.snapshotStats().batches == before + (expectedIndependent > 1 ? 1u : 0u) + && ProcessorSafety::isFinite(parallelOutput); + }; + const bool effectAdded = parallel->addTrackBuiltInFX(trackId(0), "OpenStudio EQ", false); + const bool pluginFallback = effectAdded && mixedBlock(trackCount - 2); + const bool effectRemoved = effectAdded && parallel->removeTrackFX(trackId(0), 1); + const int send = parallel->addTrackSend(trackId(0), trackId(1)); + const bool sendFallback = send >= 0 && mixedBlock(trackCount - 3); + if (send >= 0) parallel->removeTrackSend(trackId(0), send); + const bool sidechainAdded = parallel->setSidechainSource(trackId(1), 0, trackId(0)); + const bool sidechainFallback = sidechainAdded && mixedBlock(trackCount - 3); + if (sidechainAdded) parallel->clearSidechainSource(trackId(1), 0); + check("mixed_graph_native_subset_" + juce::String(trackCount), pluginFallback && effectRemoved + && sendFallback && sidechainFallback, + "Non-NAM and connected tracks retain serial processing; two or more remaining isolated NAM tracks still dispatch as a batch."); + + // Real lifecycle methods drain the executor before buffer reprepare. + parallel->audioDeviceStopped(); + const bool stopped = parallel->pendingParallelSnapshot == nullptr && parallel->realtimeTrackExecutor.isIdle() + && parallel->realtimeTrackExecutor.getWorkerCount() == 0; + parallel->audioDeviceAboutToStart(&device); + const auto beforeRestart = parallel->realtimeTrackExecutor.snapshotStats().completedBatches; + fillInput(101); + callback(*parallel, parallelOutput, capacity); + check("device_restart_" + juce::String(trackCount), stopped + && parallel->realtimeTrackExecutor.getWorkerCount() == trackCount - 1 + && parallel->realtimeTrackExecutor.snapshotStats().completedBatches == beforeRestart + 1 + && ProcessorSafety::isFinite(parallelOutput) && parallel->pendingParallelSnapshot == nullptr, + "Device stop drains and joins helpers; reprepare rebuilds scratch and resumes the eligible graph."); + configurations.add(parallel->getRealtimeTrackExecutionDiagnostics()); + serial->audioDeviceStopped(); + parallel->audioDeviceStopped(); + } + + // Routing doubles deliberately avoid NAM DSP: the oracle below is plain + // input + send + external key arithmetic, independent of the refactored + // host callback. Real NAM DSP parity is covered by the cases above. + struct PartialContext + { + std::atomic workerLane { -1 }, callbackLane { -1 }; + std::atomic release { false }; + juce::int64 coordinationDeadline = 0; + }; + class FixtureNAM final : public OpenStudioNAMRack + { + public: + FixtureNAM(int index, PartialContext* partial = nullptr) + : lane(index), callbackThread(std::this_thread::get_id()), coordination(partial) {} + void prepareToPlay(double rate, int size) override + { setRateAndBufferSizeDetails(rate, size); setLatencySamples(0); } + void releaseResources() override {} + void reset() override {} + double getTailLengthSeconds() const override { return 0.0; } + void processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) override + { + juce::ignoreUnused(midi); + const bool onCallback = std::this_thread::get_id() == callbackThread; + (onCallback ? callbackCalls : workerCalls).fetch_add(1); + if (coordination == nullptr) return; + if (onCallback) + { + coordination->callbackLane.store(lane); + while (coordination->workerLane.load() < 0 + && juce::Time::getHighResolutionTicks() < coordination->coordinationDeadline) + std::this_thread::yield(); + } + else + { + coordination->workerLane.store(lane); + while (!coordination->release.load()) juce::Thread::sleep(1); + } + const float value = lane == 0 ? 0.125f : 0.25f; + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + juce::FloatVectorOperations::fill(buffer.getWritePointer(channel), value, buffer.getNumSamples()); + } + std::atomic callbackCalls { 0 }, workerCalls { 0 }; + private: + const int lane; + const std::thread::id callbackThread; + PartialContext* const coordination; + }; + class FixtureKey final : public OpenStudioEQ + { + public: + FixtureKey() : OpenStudioEQ(true), callbackThread(std::this_thread::get_id()) {} + void prepareToPlay(double rate, int size) override + { setRateAndBufferSizeDetails(rate, size); setLatencySamples(0); } + void releaseResources() override {} + void reset() override {} + void processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) override + { + juce::ignoreUnused(midi); + callbackOnly = callbackOnly && std::this_thread::get_id() == callbackThread; + validKey = validKey && buffer.getNumChannels() >= 4; + if (buffer.getNumChannels() < 4) return; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + buffer.setSample(channel, sample, buffer.getSample(channel, sample) + + 0.25f * buffer.getSample(channel + 2, sample)); + } + bool callbackOnly = true, validKey = true; + private: + const std::thread::id callbackThread; + }; + const auto configureUnityTrack = [](TrackProcessor& track) + { + track.setVolume(0.0f); + track.setPanLaw(PanLaw::Linear); + track.setPan(0.0f); + track.setDCOffsetRemoval(false); + track.setChannelStripEQEnabled(false); + track.setRecordArmed(true); + track.setInputMonitoring(true); + }; + for (const auto precision : { ProcessingPrecisionMode::Float32, ProcessingPrecisionMode::Hybrid64 }) + { + std::array, 2> engines; + std::array, 2> native {}; + std::array key {}; + bool ready = true, exact = true, expected = true, subset = true; + juce::Array scenarioDiagnostics; + for (size_t index = 0; index < engines.size(); ++index) + { + auto& engine = engines[index]; + engine = std::make_unique(DeviceStartup::offline); + engine->processingPrecisionMode = precision; + engine->requestedRealtimeWorkers = index == 0 ? 0 : 3; + engine->audioDeviceAboutToStart(&device); + engine->setPanLaw("linear"); + engine->setMasterVolume(1.0f); + for (int lane = 0; lane < 4; ++lane) + { + const auto id = trackId(lane); + ready = engine->addTrack(id, "audio") == id && ready; + auto* track = engine->trackMap.at(id); + configureUnityTrack(*track); + engine->setTrackInputChannels(id, lane, 1); + if (lane == 1) + { + auto effect = std::make_unique(); + key[index] = effect.get(); + ready = track->addTrackFX(std::move(effect), 48000.0, capacity) && ready; + } + else + { + auto effect = std::make_unique(lane); + native[index][static_cast(lane)] = effect.get(); + ready = track->addTrackFX(std::move(effect), 48000.0, capacity) && ready; + } + } + engine->setTrackMasterSendEnabled(trackId(0), false); + const int send = engine->addTrackSend(trackId(0), trackId(1)); + ready = send == 0 && ready; + engine->setTrackSendLevel(trackId(0), send, 0.5f); + } + // Send only, key only, both, then a one-channel hardware output route; + // repeat all routes while playing as well as while stopped. + for (int scenario = 0; scenario < 8; ++scenario) + { + const int routeScenario = scenario % 4; + const bool sendEnabled = routeScenario != 1; + const bool keyEnabled = routeScenario != 0; + for (auto& engine : engines) + { + engine->isPlaying.store(scenario >= 4); + engine->setTrackSendEnabled(trackId(0), 0, sendEnabled); + ready = engine->setSidechainSource(trackId(1), 0, keyEnabled ? trackId(0) : juce::String()) && ready; + engine->setTrackOutputChannels(trackId(1), routeScenario == 3 ? 1 : 0, routeScenario == 3 ? 1 : 2); + } + for (int sample = 0; sample < capacity; ++sample) + { + input.setSample(0, sample, static_cast(sample % 17 - 8) * 0.013f); + input.setSample(1, sample, static_cast(sample % 11 - 5) * 0.041f); + input.setSample(2, sample, static_cast(sample % 13 - 6) * 0.017f); + input.setSample(3, sample, static_cast(sample % 7 - 3) * 0.031f); + } + const auto batchesBefore = engines[1]->realtimeTrackExecutor.snapshotStats().batches; + callback(*engines[0], serialOutput, capacity); + callback(*engines[1], parallelOutput, capacity); + exact = exactOutput(capacity) && exact; + subset = engines[1]->realtimeTrackExecutor.snapshotStats().batches == batchesBefore + 1 && subset; + auto* scenarioResult = new juce::DynamicObject(); + scenarioResult->setProperty("scenario", scenario); + scenarioResult->setProperty("serialParallelExact", exactOutput(capacity)); + scenarioResult->setProperty("batchesAdded", static_cast(engines[1]->realtimeTrackExecutor.snapshotStats().batches - batchesBefore)); + double maximumError = 0.0; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < capacity; ++sample) + { + // In the one-channel output case the destination's left + // sample is routed to device right; the right sample is omitted. + const int sendChannel = routeScenario == 3 ? 0 : channel; + const float sendPan = sendChannel == 0 + ? std::cos(juce::MathConstants::pi / 4.0f) + : std::sin(juce::MathConstants::pi / 4.0f); + float destination = sendEnabled ? input.getSample(0, sample) * (sendPan * 0.5f) : 0.0f; + destination += input.getSample(1, sample); + if (keyEnabled) destination += 0.25f * input.getSample(0, sample); + if (routeScenario == 3 && channel == 0) destination = 0.0f; + float sum = destination; + if (precision == ProcessingPrecisionMode::Float32) + { + sum += input.getSample(2, sample); + sum += input.getSample(3, sample); + } + else + sum = static_cast(static_cast(destination) + + static_cast(input.getSample(2, sample)) + static_cast(input.getSample(3, sample))); + const float actual = parallelOutput.getSample(channel, sample); + const double error = std::abs(static_cast(actual) - sum); + maximumError = juce::jmax(maximumError, error); + if (error > 0.0000002 && !scenarioResult->hasProperty("firstMismatchSample")) + { + scenarioResult->setProperty("firstMismatchChannel", channel); + scenarioResult->setProperty("firstMismatchSample", sample); + scenarioResult->setProperty("expected", sum); + scenarioResult->setProperty("actual", actual); + } + expected = error <= 0.0000002 && expected; + } + scenarioResult->setProperty("maximumError", maximumError); + scenarioDiagnostics.add(scenarioResult); + } + for (size_t index = 0; index < engines.size(); ++index) + { + subset = native[index][0]->workerCalls.load() == 0 && native[index][0]->callbackCalls.load() == 8 + && key[index]->callbackOnly && key[index]->validKey && subset; + engines[index]->audioDeviceStopped(); + } + auto* diagnostics = new juce::DynamicObject(); + diagnostics->setProperty("ready", ready); + diagnostics->setProperty("exact", exact); + diagnostics->setProperty("expected", expected); + diagnostics->setProperty("subset", subset); + diagnostics->setProperty("scenarios", scenarioDiagnostics); + check(precision == ProcessingPrecisionMode::Float32 ? "mixed_routing_float32" : "mixed_routing_hybrid64", + ready && exact && expected && subset, + "While stopped and playing, send-only/key-only/combined routes and one-channel output match explicit per-sample arithmetic within 2e-7 and serial output exactly. Connected NAM and the key effect execute only on callback; the isolated NAM pair still dispatches in the mixed graph.", diagnostics); + } + + { + PartialContext partial; + auto engine = std::make_unique(DeviceStartup::offline); + engine->requestedRealtimeWorkers = 1; + engine->audioDeviceAboutToStart(&device); + engine->setPanLaw("linear"); + engine->setMasterVolume(1.0f); + bool ready = true; + for (int lane = 0; lane < 2; ++lane) + { + const auto id = trackId(lane); + ready = engine->addTrack(id, "audio") == id && ready; + auto* track = engine->trackMap.at(id); + configureUnityTrack(*track); + engine->setTrackInputChannels(id, lane, 1); + ready = track->addTrackFX(std::make_unique(lane, &partial), 48000.0, capacity) && ready; + } + partial.coordinationDeadline = juce::Time::getHighResolutionTicks() + + juce::Time::getHighResolutionTicksPerSecond() / 4; + fillInput(0); + engine->audioDeviceIOCallbackWithContext(input.getArrayOfReadPointers(), input.getNumChannels(), + parallelOutput.getArrayOfWritePointers(), 2, capacity, {}); + const int completeLane = partial.callbackLane.load(); + bool partialPass = ready && completeLane >= 0 && partial.workerLane.load() >= 0 + && completeLane != partial.workerLane.load() && engine->pendingParallelSnapshot != nullptr; + const float completeValue = completeLane == 0 ? 0.125f : 0.25f; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < capacity; ++sample) + partialPass = parallelOutput.getSample(channel, sample) == completeValue && partialPass; + // Release only after inspecting the partial mix. A later callback may + // not replay the late lane's old block as fresh output. + partial.release.store(true); + engine->drainRealtimeTrackExecution(); + engine->setTrackRecordArm(trackId(0), false); + engine->setTrackRecordArm(trackId(1), false); + engine->setTrackInputMonitoring(trackId(0), false); + engine->setTrackInputMonitoring(trackId(1), false); + engine->setTrackMute(trackId(0), true); + engine->setTrackMute(trackId(1), true); + callback(*engine, parallelOutput, capacity); + partialPass = parallelOutput.getMagnitude(0, capacity) == 0.0f + && engine->pendingParallelSnapshot == nullptr && partialPass; + check("partial_completed_lane_is_reduced_once", partialPass, + "A real host callback mixes the exact completed-lane signal while another native-type fixture remains Running. After drain and mute, no late output is replayed in the following callback."); + engine->audioDeviceStopped(); + } + + // Raw recording is taken before the track job, including when another + // armed track is soloed. Compare decoded PCM rather than WAV metadata. + const auto recordingDirectory = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("OpenStudio-parallel-recording-" + juce::Uuid().toString()); + bool recordingPass = recordingDirectory.createDirectory().wasOk(); + { + auto serial = createEngine(4, 0); + auto parallel = createEngine(4, 3); + constexpr int recordingBlocks = 8; + for (auto* engine : { serial.get(), parallel.get() }) + { + const auto prefix = engine == serial.get() ? "serial-" : "parallel-"; + for (int index = 0; index < 4; ++index) + recordingPass = engine->audioRecorder.startRecording(trackId(index), + recordingDirectory.getChildFile(prefix + juce::String(index) + ".wav"), 48000.0, 1) && recordingPass; + engine->setTrackSolo(trackId(0), true); + engine->isRecordMode.store(true); + engine->isPlaying.store(true); + } + for (int block = 0; block < recordingBlocks; ++block) + { + fillInput(block); + callback(*serial, serialOutput, capacity); + callback(*parallel, parallelOutput, capacity); + } + for (auto* engine : { serial.get(), parallel.get() }) + { + engine->isPlaying.store(false); + engine->isRecordMode.store(false); + const auto completed = engine->audioRecorder.stopAllRecordings(48000.0); + recordingPass = completed.size() == 4 && engine->audioRecorder.getWriteLockMissCount() == 0 + && engine->audioRecorder.getWriterBufferOverflowCount() == 0 && recordingPass; + } + juce::WavAudioFormat format; + for (int index = 0; index < 4; ++index) + { + const auto read = [&](const char* prefix) + { + auto stream = recordingDirectory.getChildFile(prefix + juce::String(index) + ".wav").createInputStream(); + if (stream == nullptr) return std::unique_ptr(); + return std::unique_ptr(format.createReaderFor( + stream.release(), true)); + }; + auto serialReader = read("serial-"); + auto parallelReader = read("parallel-"); + const int frames = recordingBlocks * capacity; + const bool readersValid = serialReader != nullptr && parallelReader != nullptr + && serialReader->lengthInSamples == frames && parallelReader->lengthInSamples == frames + && serialReader->numChannels == 1 && parallelReader->numChannels == 1; + recordingPass = readersValid && recordingPass; + if (!readersValid) continue; + juce::AudioBuffer serialTake(1, frames), parallelTake(1, frames); + recordingPass = serialReader->read(&serialTake, 0, frames, 0, true, false) + && parallelReader->read(¶llelTake, 0, frames, 0, true, false) && recordingPass; + for (int frame = 0; frame < frames; ++frame) + { + const float expected = static_cast(0.1 * std::sin(0.017 * frame + 0.29 * index)); + recordingPass = serialTake.getSample(0, frame) == parallelTake.getSample(0, frame) + && std::abs(parallelTake.getSample(0, frame) - expected) <= 2.0f / 32768.0f && recordingPass; + } + } + serial->audioDeviceStopped(); + parallel->audioDeviceStopped(); + } + bool controlDrainPass = true; + for (const int variant : { 0, 1, 2 }) + { + const bool mixedGraph = variant > 0; + const bool addIncomingWhilePending = variant == 2; + bool quarantinePass = true; + auto* quarantineDiagnostics = new juce::DynamicObject(); + juce::var quarantineDiagnosticValue(quarantineDiagnostics); + double maximumOutputError = 0.0, maximumRecordingError = 0.0; + bool ownedScratchIntact = true; + const int trackCount = mixedGraph ? 6 : 4; + const juce::String filePrefix = addIncomingWhilePending ? "new-send-quarantine-" + : mixedGraph ? "mixed-quarantine-" : "quarantine-"; + auto engine = createEngine(trackCount, 1); + std::array serialFixtures {}; + int sourceDelay = 0, destinationDelay = 0; + const auto fixtureSample = [](int channel, int frame) + { + return frame < 0 ? 0.0f : static_cast(0.1 * std::sin(0.017 * frame + 0.29 * channel)); + }; + if (mixedGraph) + { + engine->setPanLaw("linear"); + engine->setMasterVolume(1.0f); + for (int lane = 4; lane < trackCount; ++lane) + { + auto* track = engine->trackMap.at(trackId(lane)); + configureUnityTrack(*track); + quarantinePass = engine->removeTrackFX(trackId(lane), 0) && quarantinePass; + auto effect = std::make_unique(lane); + serialFixtures[static_cast(lane - 4)] = effect.get(); + quarantinePass = track->addTrackFX(std::move(effect), 48000.0, capacity) && quarantinePass; + } + engine->setTrackMasterSendEnabled(trackId(4), false); + const int send = engine->addTrackSend(trackId(4), trackId(5)); + quarantinePass = send >= 0 && quarantinePass; + if (send >= 0) engine->setTrackSendLevel(trackId(4), send, 0.5f); + // The retained real NAM processors report their real latency. + // Preserve the resulting compensation on the routing doubles, + // but start its history at the configured delay rather than an + // unrelated live 0 -> delay transition. The oracle below includes + // both zero-padded delay lines and the recorder's pre-FX position. + sourceDelay = engine->trackMap.at(trackId(4))->getPDCDelay(); + destinationDelay = engine->trackMap.at(trackId(5))->getPDCDelay(); + engine->trackMap.at(trackId(4))->resetPDCDelayState(); + engine->trackMap.at(trackId(5))->resetPDCDelayState(); + } + const auto* snapshot = engine->realtimeTrackSnapshotForAudio.load(); + auto* heldTrack = engine->trackMap.at(trackId(0)); + auto* heldRack = dynamic_cast(heldTrack->getTrackFXProcessor(0)); + const auto processedBefore = heldRack != nullptr + ? static_cast(heldRack->getDiagnosticState().getProperty("processedBlockCount", -1)) : -1; + quarantinePass = heldRack != nullptr && processedBefore >= 0 && quarantinePass; + for (size_t index = 0; index < snapshot->tracks.size(); ++index) + { + const auto& entry = snapshot->tracks[index]; + entry.work->jobIndex = index < 4 ? static_cast(index) : -1; + if (index < 4) + for (int channel = 0; channel < entry.work->buffer.getNumChannels(); ++channel) + juce::FloatVectorOperations::fill(entry.work->buffer.getWritePointer(channel), poison, capacity); + } + for (int index = 0; index < trackCount; ++index) + quarantinePass = engine->audioRecorder.startRecording(trackId(index), + recordingDirectory.getChildFile(filePrefix + juce::String(index) + ".wav"), 48000.0, 1) && quarantinePass; + engine->isRecordMode.store(true); + engine->isPlaying.store(true); + quarantineDiagnostics->setProperty("setupPass", quarantinePass); + if (mixedGraph) + { + quarantineDiagnostics->setProperty("sourcePDCDelay", engine->trackMap.at(trackId(4))->getPDCDelay()); + quarantineDiagnostics->setProperty("destinationPDCDelay", engine->trackMap.at(trackId(5))->getPDCDelay()); + } + + struct HeldJob + { + TrackProcessor* track = nullptr; + std::thread::id callbackThread; + std::atomic entered { false }, release { false }; + juce::int64 coordinationDeadline = 0; + static void run(void* pointer) noexcept + { + auto& job = *static_cast(pointer); + if (std::this_thread::get_id() == job.callbackThread) + { + while (!job.entered.load() && juce::Time::getHighResolutionTicks() < job.coordinationDeadline) + std::this_thread::yield(); + return; + } + // Fault fixture only: retain exactly the ownership lease an + // in-flight native job holds, without concurrently editing DSP. + const juce::ScopedLock lock(job.track->getCallbackLock()); + job.entered.store(true); + while (!job.release.load()) juce::Thread::sleep(1); + } + } held; + held.track = heldTrack; + held.callbackThread = std::this_thread::get_id(); + held.coordinationDeadline = juce::Time::getHighResolutionTicks() + + juce::Time::getHighResolutionTicksPerSecond() / 4; + std::array jobs; + for (auto& job : jobs) { job.context = &held; job.run = HeldJob::run; } + engine->realtimeSnapshotAudioReaders.fetch_add(1, std::memory_order_seq_cst); + engine->pendingParallelSnapshot = snapshot; + const bool missed = !engine->realtimeTrackExecutor.runBatch(jobs.data(), static_cast(jobs.size()), + juce::Time::getHighResolutionTicks() + juce::Time::getHighResolutionTicksPerSecond() / 20); + quarantinePass = held.entered.load() && missed && !engine->realtimeTrackExecutor.isIdle() && quarantinePass; + quarantineDiagnostics->setProperty("heldJobWitness", held.entered.load() && missed && !engine->realtimeTrackExecutor.isIdle()); + if (addIncomingWhilePending) + { + const int send = engine->addTrackSend(trackId(4), trackId(1)); + quarantinePass = send >= 0 && quarantinePass; + if (send >= 0) engine->setTrackSendLevel(trackId(4), send, 0.25f); + quarantinePass = engine->realtimeTrackSnapshotForAudio.load() != snapshot && quarantinePass; + } + const auto quarantinedBefore = engine->parallelQuarantinedBlocks.load(); + const auto batchesBefore = engine->realtimeTrackExecutor.snapshotStats().batches; + constexpr int quarantinedCallbacks = 3; + for (int block = 0; block < quarantinedCallbacks; ++block) + { + fillInput(block); + // Do not use callback(): its control drain intentionally waits for + // workers, whereas this fixture needs the live quarantine branch. + engine->audioDeviceIOCallbackWithContext(input.getArrayOfReadPointers(), input.getNumChannels(), + parallelOutput.getArrayOfWritePointers(), 2, capacity, {}); + quarantinePass = ProcessorSafety::isFinite(parallelOutput) && quarantinePass; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < capacity; ++sample) + { + const float sendPan = channel == 0 ? std::cos(juce::MathConstants::pi / 4.0f) + : std::sin(juce::MathConstants::pi / 4.0f); + const int frame = block * capacity + sample; + const float expected = mixedGraph + ? fixtureSample(4, frame - sourceDelay - destinationDelay) * (sendPan * 0.5f) + + fixtureSample(5, frame - destinationDelay) + : 0.0f; + const float actual = parallelOutput.getSample(channel, sample); + const double error = std::abs(static_cast(actual) - expected); + maximumOutputError = juce::jmax(maximumOutputError, error); + if (error > 0.0000002 && !quarantineDiagnostics->hasProperty("firstOutputMismatchSample")) + { + quarantineDiagnostics->setProperty("firstOutputMismatchBlock", block); + quarantineDiagnostics->setProperty("firstOutputMismatchChannel", channel); + quarantineDiagnostics->setProperty("firstOutputMismatchSample", sample); + quarantineDiagnostics->setProperty("outputExpected", expected); + quarantineDiagnostics->setProperty("outputActual", actual); + } + quarantinePass = error <= 0.0000002 + && quarantinePass; + } + for (size_t index = 0; index < 4; ++index) + { + const auto& entry = snapshot->tracks[index]; + for (int channel = 0; channel < entry.work->buffer.getNumChannels(); ++channel) + for (int sample = 0; sample < capacity; ++sample) + ownedScratchIntact = entry.work->buffer.getSample(channel, sample) == poison && ownedScratchIntact; + } + } + quarantinePass = engine->parallelQuarantinedBlocks.load() == quarantinedBefore + quarantinedCallbacks + && engine->realtimeTrackExecutor.snapshotStats().batches == batchesBefore + && heldRack != nullptr + && static_cast(heldRack->getDiagnosticState().getProperty("processedBlockCount", -1)) == processedBefore + && ownedScratchIntact && quarantinePass; + quarantineDiagnostics->setProperty("ownedScratchIntact", ownedScratchIntact); + quarantineDiagnostics->setProperty("maximumOutputError", maximumOutputError); + quarantineDiagnostics->setProperty("newBatches", static_cast(engine->realtimeTrackExecutor.snapshotStats().batches - batchesBefore)); + if (mixedGraph) + for (const auto* fixture : serialFixtures) + quarantinePass = fixture != nullptr && fixture->callbackCalls.load() == quarantinedCallbacks + && fixture->workerCalls.load() == 0 && quarantinePass; + + std::atomic stopEntered { false }, stopFinished { false }; + std::thread stopper([&] + { + stopEntered.store(true); + engine->audioDeviceStopped(); + stopFinished.store(true); + }); + while (!stopEntered.load()) std::this_thread::yield(); + juce::Thread::sleep(5); + controlDrainPass = !stopFinished.load() && controlDrainPass; + held.release.store(true); + stopper.join(); + controlDrainPass = stopFinished.load() && engine->pendingParallelSnapshot == nullptr + && engine->realtimeSnapshotAudioReaders.load() == 0 && engine->realtimeTrackExecutor.isIdle() + && engine->realtimeTrackExecutor.getWorkerCount() == 0 && controlDrainPass; + engine->isRecordMode.store(false); + engine->isPlaying.store(false); + const auto completed = engine->audioRecorder.stopAllRecordings(48000.0); + quarantinePass = completed.size() == static_cast(trackCount) && engine->audioRecorder.getWriteLockMissCount() == 0 + && engine->audioRecorder.getWriterBufferOverflowCount() == 0 && quarantinePass; + quarantineDiagnostics->setProperty("completedRecordings", static_cast(completed.size())); + quarantineDiagnostics->setProperty("passBeforeReadingRecordings", quarantinePass); + juce::WavAudioFormat format; + for (int index = 0; index < trackCount; ++index) + { + auto stream = recordingDirectory.getChildFile(filePrefix + juce::String(index) + ".wav").createInputStream(); + std::unique_ptr reader(stream != nullptr ? format.createReaderFor(stream.release(), true) : nullptr); + const int frames = quarantinedCallbacks * capacity; + const bool valid = reader != nullptr && reader->numChannels == 1 && reader->lengthInSamples == frames; + quarantinePass = valid && quarantinePass; + if (!valid) continue; + juce::AudioBuffer take(1, frames); + quarantinePass = reader->read(&take, 0, frames, 0, true, false) && quarantinePass; + for (int frame = 0; frame < frames; ++frame) + { + float expected = fixtureSample(index, frame); + if (mixedGraph && index == 5) + expected += fixtureSample(4, frame - sourceDelay) + * (std::cos(juce::MathConstants::pi / 4.0f) * 0.5f); + if (addIncomingWhilePending && index == 1) + expected += fixtureSample(4, frame - sourceDelay) + * (std::cos(juce::MathConstants::pi / 4.0f) * 0.25f); + const float actual = take.getSample(0, frame); + const double error = std::abs(static_cast(actual) - expected); + maximumRecordingError = juce::jmax(maximumRecordingError, error); + if (error > 2.0 / 32768.0 && !quarantineDiagnostics->hasProperty("firstRecordingMismatchFrame")) + { + quarantineDiagnostics->setProperty("firstRecordingMismatchTrack", index); + quarantineDiagnostics->setProperty("firstRecordingMismatchFrame", frame); + quarantineDiagnostics->setProperty("recordingExpected", expected); + quarantineDiagnostics->setProperty("recordingActual", actual); + } + quarantinePass = error <= 2.0 / 32768.0 && quarantinePass; + } + } + quarantineDiagnostics->setProperty("maximumRecordingError", maximumRecordingError); + check(addIncomingWhilePending ? "pending_worker_new_incoming_send_recording" + : mixedGraph ? "pending_worker_mixed_send_recording" : "pending_worker_quarantine_preserves_raw_recording", quarantinePass, + addIncomingWhilePending + ? "A new snapshot adds an unowned source send into an old queued processor. Identity quarantine retains all old owned scratch while that raw take includes the current incoming send; unrelated send monitoring/recording follows an explicit zero-padded PDC delay oracle without new dispatch." + : mixedGraph + ? "Four retained native owners remain untouched while an unrelated source/send/destination graph processes on callback. Three blocks match an explicit zero-padded PDC delay oracle for monitoring and raw send-destination PCM, and no new worker batch is dispatched." + : "A deliberately held helper retains a track lock and snapshot pin across three real host callbacks; monitoring is silent, DSP counters/scratch remain untouched and all four raw recordings preserve exact frame counts and 16-bit input parity.", quarantineDiagnosticValue); + } + check("device_stop_waits_for_pending_worker", controlDrainPass, + "The real audioDeviceStopped control path cannot finish while the helper owns its track; after release it drains, joins workers and retires the snapshot reader pin."); + const bool temporaryFilesRemoved = recordingDirectory.deleteRecursively(); + check("raw_recording_serial_parallel", recordingPass, + "Four armed mono sources retain identical decoded 16-bit PCM and exact frame counts before Drive/pan/solo."); + check("temporary_recordings_cleaned", temporaryFilesRemoved, + "The unique temporary fixture directory is removed after readers, writers, worker jobs and engines close."); + auto* result = new juce::DynamicObject(); + result->setProperty("pass", passed); + result->setProperty("success", passed); + result->setProperty("checks", checks); + result->setProperty("configurations", configurations); + result->setProperty("scope", "Offline full-host routing, exact 512-frame output, raw recording, scratch bounds and lifecycle contracts. No model/IR resources or physical audio device are opened."); + result->setProperty("tinyBufferCapacity", "diagnostic_only"); + result->setProperty("audioQuality", "not_asserted"); + return result; +} diff --git a/Source/AudioFileConversion.cpp b/Source/AudioFileConversion.cpp index f8383d0..1077f82 100644 --- a/Source/AudioFileConversion.cpp +++ b/Source/AudioFileConversion.cpp @@ -64,13 +64,14 @@ juce::Result AudioFileConversion::convert(const juce::File& source, const juce:: if (options.format == "wav") arguments.addArray({ "-rf64", "auto" }); arguments.addArray({ "-f", options.format, staged.getFile().getFullPathName() }); OwnedChildProcess process; - if (cancelled() || !process.start(arguments)) return juce::Result::fail("Could not start audio conversion"); const auto started = juce::Time::getMillisecondCounterHiRes(); + const auto timedOut = [&] { return juce::Time::getMillisecondCounterHiRes() - started >= options.timeoutMs; }; + if (cancelled() || !process.start(arguments)) return juce::Result::fail("Could not start audio conversion"); juce::MemoryOutputStream diagnostic; bool interrupted = false; while (process.isRunning()) { - if (cancelled() || juce::Time::getMillisecondCounterHiRes() - started >= options.timeoutMs) + if (cancelled() || timedOut()) { interrupted = true; process.kill(); @@ -87,7 +88,9 @@ juce::Result AudioFileConversion::convert(const juce::File& source, const juce:: } // A terminated process must relinquish the staged file before cleanup. const std::atomic waitForExit { true }; - if (!process.waitForProcessToFinish(2000, waitForExit) || interrupted || cancelled()) + // The child can finish between polls after its deadline. A successful exit + // must not bypass the budget merely because isRunning() became false. + if (!process.waitForProcessToFinish(2000, waitForExit) || interrupted || cancelled() || timedOut()) return juce::Result::fail("Conversion cancelled or timed out; original files were preserved"); if (process.getExitCode() != 0) return juce::Result::fail("Audio conversion failed: " + diagnostic.toString().substring(0, 2048)); @@ -103,7 +106,7 @@ juce::Result AudioFileConversion::convert(const juce::File& source, const juce:: publicationStream->flush(); const bool flushed = publicationStream->getStatus().wasOk(); publicationStream.reset(); - if (!flushed || cancelled() || !staged.overwriteTargetFileWithTemporary()) + if (!flushed || cancelled() || timedOut() || !staged.overwriteTargetFileWithTemporary()) return juce::Result::fail("Could not publish converted audio; the original destination was preserved"); return juce::Result::ok(); } diff --git a/Source/AudioFileConversionRegression.cpp b/Source/AudioFileConversionRegression.cpp index 3e79612..c6795aa 100644 --- a/Source/AudioFileConversionRegression.cpp +++ b/Source/AudioFileConversionRegression.cpp @@ -1,5 +1,6 @@ #include "AudioFileConversionRegression.h" #include "AudioFileConversion.h" +#include "FFmpegLocator.h" #include "OwnedChildProcess.h" #include @@ -25,6 +26,17 @@ std::unique_ptr openFixture(const juce::File& file) formats.registerBasicFormats(); return std::unique_ptr(formats.createReaderFor(file)); } + +juce::Result convertFixture(const juce::File& source, const juce::File& destination, + const AudioFileConversion::Options& options, + const std::function& keepRunning) +{ + const auto result = AudioFileConversion::convert(source, destination, options, keepRunning); + if (result.failed()) + juce::Logger::writeToLog("Audio conversion fixture " + source.getFileName() + " -> " + + destination.getFileName() + ": " + result.getErrorMessage()); + return result; +} } void runAudioFileConversionRegression(const juce::File& root, const std::function& check) @@ -55,6 +67,10 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio #endif const auto directory = root.getChildFile("conversion"); directory.createDirectory(); + const auto ffmpeg = OpenStudioFFmpeg::findExecutable(); + check("converter_ffmpeg_runtime_available", ffmpeg.existsAsFile()); + juce::Logger::writeToLog("Audio conversion fixture FFmpeg: " + + (ffmpeg.existsAsFile() ? ffmpeg.getFullPathName() : "not found in the bundle or PATH")); const auto alive = [] { return true; }; AudioFileConversion::Options options; options.bitDepth = 24; @@ -67,7 +83,7 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio { options.sampleRate = outputRate; const auto output = directory.getChildFile(juce::String(sourceRate) + "-" + juce::String(outputRate) + ".wav"); - const auto result = AudioFileConversion::convert(source, output, options, alive); + const auto result = convertFixture(source, output, options, alive); auto reader = openFixture(output); ratesPass = result.wasOk() && reader && reader->sampleRate == outputRate && reader->lengthInSamples == outputRate / 5 && ratesPass; @@ -89,7 +105,7 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio check("converter_stereo_fixture_written", writeFixture(stereo, 48000, 2, 9600, 1000)); options.sampleRate = 48000; const auto same = directory.getChildFile("same.wav"); - bool parity = AudioFileConversion::convert(stereo, same, options, alive).wasOk(); + bool parity = convertFixture(stereo, same, options, alive).wasOk(); auto original = openFixture(stereo); auto copied = openFixture(same); if (original && copied) @@ -104,7 +120,7 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio check("converter_same_rate_same_depth_sample_parity", parity); options.channels = 1; const auto mono = directory.getChildFile("mono.wav"); - bool monoPass = AudioFileConversion::convert(stereo, mono, options, alive).wasOk(); + bool monoPass = convertFixture(stereo, mono, options, alive).wasOk(); auto downmix = openFixture(mono); if (downmix) { @@ -118,7 +134,7 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio check("converter_stereo_to_mono_uses_explicit_average", monoPass); options.channels = 2; const auto duplicated = directory.getChildFile("duplicated.wav"); - bool stereoPass = AudioFileConversion::convert(mono, duplicated, options, alive).wasOk(); + bool stereoPass = convertFixture(mono, duplicated, options, alive).wasOk(); auto upmix = openFixture(duplicated); if (upmix) { @@ -134,7 +150,7 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio writeFixture(high, 96000, 1, 19200, 30000); options.sampleRate = 44100; const auto filtered = directory.getChildFile("filtered.wav"); - bool aliasPass = AudioFileConversion::convert(high, filtered, options, alive).wasOk(); + bool aliasPass = convertFixture(high, filtered, options, alive).wasOk(); auto filteredReader = openFixture(filtered); if (filteredReader) { @@ -150,7 +166,7 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio const auto source = directory.getChildFile("short-" + juce::String(length) + ".wav"); const auto output = directory.getChildFile("short-out-" + juce::String(length) + ".wav"); writeFixture(source, 48000, 1, length, 1000); - shortPass = AudioFileConversion::convert(source, output, options, alive).wasOk() && shortPass; + shortPass = convertFixture(source, output, options, alive).wasOk() && shortPass; auto reader = openFixture(output); shortPass = reader && reader->lengthInSamples == std::llround(length * 44100.0 / 48000) && shortPass; } @@ -160,31 +176,52 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio { options.format = format; const auto output = directory.getChildFile(juce::String("format.") + format); - formatsPass = AudioFileConversion::convert(stereo, output, options, alive).wasOk() && formatsPass; + formatsPass = convertFixture(stereo, output, options, alive).wasOk() && formatsPass; } check("converter_wav_aiff_flac_are_finalized_and_readable", formatsPass); options.format = "wav"; const auto protectedOutput = directory.getChildFile("preserve.wav"); - protectedOutput.replaceWithText("existing destination"); check("converter_pre_cancel_preserves_existing_destination", - AudioFileConversion::convert(stereo, protectedOutput, options, [] { return false; }).failed() + protectedOutput.replaceWithText("existing destination") + && AudioFileConversion::convert(stereo, protectedOutput, options, [] { return false; }).failed() && protectedOutput.loadFileAsString() == "existing destination"); int cancellationChecks = 0; check("converter_running_child_cancel_preserves_existing_destination", - AudioFileConversion::convert(stereo, protectedOutput, options, [&] { return ++cancellationChecks < 3; }).failed() + protectedOutput.replaceWithText("existing destination") + && AudioFileConversion::convert(stereo, protectedOutput, options, [&] { return ++cancellationChecks < 3; }).failed() && protectedOutput.loadFileAsString() == "existing destination"); options.timeoutMs = 1; + const auto timeoutDirectory = directory.getChildFile("timeout"); + const auto timeoutOutput = timeoutDirectory.getChildFile("preserve.wav"); + const bool timeoutReady = timeoutDirectory.createDirectory().wasOk() + && timeoutOutput.replaceWithText("existing destination"); + bool delayedAfterStaging = false; + const auto allowConversion = [&] { + // Keep a real FFmpeg child and the strict deadline. If it finishes too + // quickly to hit a polling timeout, expire the budget at the observable + // staging checkpoint. This needs neither a callback-count assumption + // nor a production clock/process override. + if (!delayedAfterStaging && timeoutDirectory.getNumberOfChildFiles(juce::File::findFiles) > 1) + { + delayedAfterStaging = true; + juce::Thread::sleep(10); + } + return true; + }; + const auto timeoutResult = AudioFileConversion::convert(stereo, timeoutOutput, options, allowConversion); check("converter_timeout_preserves_existing_destination", - AudioFileConversion::convert(stereo, protectedOutput, options, alive).failed() - && protectedOutput.loadFileAsString() == "existing destination"); + timeoutReady && timeoutResult.failed() && timeoutOutput.loadFileAsString() == "existing destination" + && timeoutDirectory.getNumberOfChildFiles(juce::File::findFiles) == 1); options.timeoutMs = 30000; options.sampleRate = -1; check("converter_invalid_rate_rejected_before_publication", - AudioFileConversion::convert(stereo, protectedOutput, options, alive).failed() + protectedOutput.replaceWithText("existing destination") + && AudioFileConversion::convert(stereo, protectedOutput, options, alive).failed() && protectedOutput.loadFileAsString() == "existing destination"); options.sampleRate = 48000; check("converter_unwritable_destination_is_reported", AudioFileConversion::convert(stereo, directory, options, alive).failed()); check("converter_invalid_input_preserves_destination", - AudioFileConversion::convert(protectedOutput, same, options, alive).failed() && openFixture(same) != nullptr); + protectedOutput.replaceWithText("existing destination") + && AudioFileConversion::convert(protectedOutput, same, options, alive).failed() && openFixture(same) != nullptr); } diff --git a/Source/AudioRecorder.cpp b/Source/AudioRecorder.cpp index 10375ca..1959e5e 100644 --- a/Source/AudioRecorder.cpp +++ b/Source/AudioRecorder.cpp @@ -32,6 +32,45 @@ static float peakFromBuffer(const juce::AudioBuffer& buffer, int numSampl #endif } +AudioRecorder::RecordingPreview::RecordingPreview(double rate, int channels) + : sampleRate(rate), + numChannels(std::min(channels, PEAK_MAX_CHANNELS)), + peakChunkEntries(static_cast(rate * PEAK_CHUNK_SECONDS / recordingPeakStride) + 2) +{ + peakChunks[0] = std::make_unique(peakChunkEntries * static_cast(numChannels * 2)); + peakChunkPtrs[0].store(peakChunks[0].get(), std::memory_order_release); + peakAllocatedChunks.store(1, std::memory_order_release); +} + +void AudioRecorder::growRecordingPreview(const std::shared_ptr& preview) +{ + if (!preview->active.load(std::memory_order_acquire)) return; + const juce::ScopedLock lock(preview->growthLock); + const size_t chunks = preview->peakAllocatedChunks.load(std::memory_order_acquire); + const size_t capacity = chunks * preview->peakChunkEntries; + const size_t completed = std::min(preview->peakTableSize.load(std::memory_order_acquire), capacity); + const size_t reserve = static_cast(preview->sampleRate * 30.0 / recordingPeakStride); + if (chunks >= RecordingPreview::PEAK_MAX_CHUNKS || capacity - completed > reserve) return; + + // At most one 120-second chunk per maintenance pass, outside writerLock. + // A retained owner and this preview-only lock also support concurrent reads. + auto chunk = std::make_unique(preview->peakChunkEntries * static_cast(preview->numChannels * 2)); + preview->peakChunks[chunks] = std::move(chunk); + preview->peakChunkPtrs[chunks].store(preview->peakChunks[chunks].get(), std::memory_order_release); + preview->peakAllocatedChunks.store(chunks + 1, std::memory_order_release); +} + +void AudioRecorder::maintainRecordingPreviews() +{ + std::vector> previews; + { + const juce::ScopedLock lock(previewRegistryLock); + previews.reserve(recordingPreviews.size()); + for (const auto& item : recordingPreviews) previews.push_back(item.second); + } + for (const auto& preview : previews) growRecordingPreview(preview); +} + AudioRecorder::AudioRecorder(const juce::File& recoveryRoot) : recoveryJournal(recoveryRoot) { // Start the background I/O thread for ThreadedWriter @@ -73,6 +112,7 @@ bool AudioRecorder::startRecordingInternal(const juce::String& trackId, double initialStartTime, CompletedRecording* replacedTake) { + const juce::ScopedLock control(controlLock); // Reject before opening a file or touching an existing take. A corrupt // device/configuration report must not become an enormous peak allocation. if (trackId.isEmpty() || !std::isfinite(sampleRate) @@ -163,14 +203,7 @@ bool AudioRecorder::startRecordingInternal(const juce::String& trackId, // Periodic header publication and checked flush, exclusively on disk thread. threadedWriter->setFlushInterval(static_cast(sampleRate * 5.0)); - // Pre-allocate incremental peak table for ~120 seconds of recording. - // Entry layout: [min_ch0, max_ch0, min_ch1, max_ch1] per PEAK_STRIDE samples. - // At 44.1kHz/256 stride: ~20 700 entries × 2 ch × 2 values × 4 B ≈ 660 KB/track. - // Zero-init so getRecordingPeaks can safely read unwritten entries as 0. - const size_t maxEntries = static_cast( - sampleRate * ActiveRecording::PEAK_CHUNK_SECONDS / ActiveRecording::PEAK_STRIDE) + 2; - const int entryFloats = std::min(numChannels, ActiveRecording::PEAK_MAX_CHANNELS) * 2; - auto peakBuf = std::unique_ptr(new float[maxEntries * static_cast(entryFloats)]()); + auto preview = std::make_shared(sampleRate, numChannels); // Brief lock: insert into map { @@ -179,6 +212,7 @@ bool AudioRecorder::startRecordingInternal(const juce::String& trackId, if (previous != activeRecordings.end()) { previous->second.isActive = false; + previous->second.preview->active.store(false, std::memory_order_release); if (replacedTake != nullptr && previous->second.threadedWriter) { replacedTake->trackId = trackId; @@ -197,7 +231,6 @@ bool AudioRecorder::startRecordingInternal(const juce::String& trackId, state.trackId = trackId; state.threadedWriter = std::move(threadedWriter); state.writeStatus = writeStatus; - pendingWriteStatuses.push_back(writeStatus); state.outputFile = file; state.isActive = true; state.startTime = initialStartTime; @@ -206,17 +239,19 @@ bool AudioRecorder::startRecordingInternal(const juce::String& trackId, state.sampleRate = sampleRate; state.captureStartOnFirstWrite = true; state.hasStartTimeFallback = replacedTake != nullptr; - for (size_t chunk = 0; chunk < ActiveRecording::PEAK_MAX_CHUNKS; ++chunk) - state.peakChunkPtrs[chunk].store(nullptr, std::memory_order_relaxed); - state.peakChunks[0] = std::move(peakBuf); - state.peakChunkPtrs[0].store(state.peakChunks[0].get(), std::memory_order_release); - state.peakAllocatedChunks.store(1, std::memory_order_release); - state.peakChunkEntries = maxEntries; - state.peakTableSize.store(0, std::memory_order_relaxed); + state.preview = preview; state.accumMin[0] = state.accumMin[1] = 0.0f; state.accumMax[0] = state.accumMax[1] = 0.0f; state.accumCount = 0; } + { + const juce::ScopedLock lock(previewRegistryLock); + recordingPreviews[trackId] = std::move(preview); + } + { + const juce::ScopedLock lock(statusLock); + pendingWriteStatuses.push_back(writeStatus); + } previousWriter.reset(); if (replacedTake != nullptr && previousWriteStatus) replacedTake->duration = previousWriteStatus->writtenSamples.load() / juce::jmax(1.0, previousSampleRate); @@ -310,10 +345,11 @@ void AudioRecorder::writeBlock(const juce::String& trackId, // Cheaper than the old interleaved-copy path: only min/max comparisons, // no index arithmetic, and the peak table is ~100× smaller than sampleBuffer. // The audio thread is the sole writer; message thread reads via atomic size. - if (state.peakAllocatedChunks.load(std::memory_order_acquire) > 0) + auto& preview = *state.preview; + if (preview.peakAllocatedChunks.load(std::memory_order_acquire) > 0) { - const int chCount = std::min(state.numChannels, ActiveRecording::PEAK_MAX_CHANNELS); - const float* chPtrs[ActiveRecording::PEAK_MAX_CHANNELS] = { nullptr, nullptr }; + const int chCount = std::min(state.numChannels, RecordingPreview::PEAK_MAX_CHANNELS); + const float* chPtrs[RecordingPreview::PEAK_MAX_CHANNELS] = { nullptr, nullptr }; chPtrs[0] = buffer.getReadPointer(0); if (chCount > 1) chPtrs[1] = buffer.getReadPointer(std::min(1, buffer.getNumChannels() - 1)); @@ -327,26 +363,29 @@ void AudioRecorder::writeBlock(const juce::String& trackId, if (v > state.accumMax[ch]) state.accumMax[ch] = v; } - if (++state.accumCount >= ActiveRecording::PEAK_STRIDE) + if (++state.accumCount >= recordingPeakStride) { - const size_t idx = state.peakTableSize.load(std::memory_order_relaxed); - const size_t chunkIndex = state.peakChunkEntries > 0 - ? idx / state.peakChunkEntries - : ActiveRecording::PEAK_MAX_CHUNKS; - if (chunkIndex < state.peakAllocatedChunks.load(std::memory_order_acquire)) + const size_t idx = preview.peakTableSize.load(std::memory_order_relaxed); + const size_t chunkIndex = preview.peakChunkEntries > 0 + ? idx / preview.peakChunkEntries + : RecordingPreview::PEAK_MAX_CHUNKS; + if (chunkIndex < preview.peakAllocatedChunks.load(std::memory_order_acquire)) { - if (float* chunk = state.peakChunkPtrs[chunkIndex].load(std::memory_order_acquire)) + if (float* chunk = preview.peakChunkPtrs[chunkIndex].load(std::memory_order_acquire)) { - const size_t entryIndex = idx % state.peakChunkEntries; + const size_t entryIndex = idx % preview.peakChunkEntries; float* entry = chunk + entryIndex * static_cast(chCount * 2); for (int ch = 0; ch < chCount; ++ch) { entry[ch * 2] = state.accumMin[ch]; entry[ch * 2 + 1] = state.accumMax[ch]; } - state.peakTableSize.store(idx + 1, std::memory_order_release); } } + // Keep time aligned if control maintenance is temporarily late. + // Missing preview storage is a gap, never compressed audio time. + if (idx < preview.peakChunkEntries * RecordingPreview::PEAK_MAX_CHUNKS) + preview.peakTableSize.store(idx + 1, std::memory_order_release); state.accumMin[0] = state.accumMin[1] = 0.0f; state.accumMax[0] = state.accumMax[1] = 0.0f; state.accumCount = 0; @@ -358,6 +397,7 @@ void AudioRecorder::writeBlock(const juce::String& trackId, void AudioRecorder::stopRecording(const juce::String& trackId) { + const juce::ScopedLock control(controlLock); std::unique_ptr writerToFlush; juce::File outputFile; { @@ -367,11 +407,16 @@ void AudioRecorder::stopRecording(const juce::String& trackId) return; it->second.isActive = false; + it->second.preview->active.store(false, std::memory_order_release); writerToFlush = std::move(it->second.threadedWriter); outputFile = it->second.outputFile; activeRecordings.erase(it); } + { + const juce::ScopedLock lock(previewRegistryLock); + recordingPreviews.erase(trackId); + } writerToFlush.reset(); juce::Logger::writeToLog("AudioRecorder: Stopped recording track " + trackId + " (" + outputFile.getFullPathName() + ")"); @@ -409,6 +454,7 @@ void AudioRecorder::setRecordingStartTime(const juce::String& trackId, double st std::vector AudioRecorder::stopAllRecordings(double currentSampleRate) { juce::ignoreUnused(currentSampleRate); + const juce::ScopedLock control(controlLock); std::map recordingsToFinalize; { const juce::ScopedLock lock(writerLock); @@ -416,10 +462,16 @@ std::vector AudioRecorder::stopAllRecordings( { juce::ignoreUnused(trackId); state.isActive = false; + state.preview->active.store(false, std::memory_order_release); } recordingsToFinalize.swap(activeRecordings); } + { + const juce::ScopedLock lock(previewRegistryLock); + recordingPreviews.clear(); + } + std::vector finalizedClips; finalizedClips.reserve(recordingsToFinalize.size()); @@ -458,7 +510,7 @@ std::vector AudioRecorder::stopAllRecordings( juce::var AudioRecorder::takeWriteFailures() { juce::Array failures; - const juce::ScopedLock lock(writerLock); + const juce::ScopedLock lock(statusLock); for (const auto& state : pendingWriteStatuses) { const auto fault = state->fault.load(std::memory_order_acquire); @@ -482,135 +534,62 @@ juce::var AudioRecorder::getRecordingPeaks(const juce::String& trackId, int numPixels, juce::int64 startSample) { - // Flat array format: [numChannels, min_ch0_px0, max_ch0_px0, min_ch1_px0, max_ch1_px0, ...] + // Preserve the requested sample scale, including old sub-stride callers. + // Bound both bridge payload and aggregation work; callers may append the + // returned prefix. New viewport clients use multiples of the 256-sample stride. juce::Array peakData; - - size_t growFromChunk = ActiveRecording::PEAK_MAX_CHUNKS; - size_t growChunkEntries = 0; - int growEntryFloats = 0; - { - const juce::ScopedLock sl(writerLock); - auto it = activeRecordings.find(trackId); - if (it == activeRecordings.end() || !it->second.isActive.load()) - return peakData; - - const auto& state = it->second; - const size_t allocatedChunks = state.peakAllocatedChunks.load(std::memory_order_acquire); - const size_t allocatedEntries = allocatedChunks * state.peakChunkEntries; - const size_t completedEntries = juce::jmin( - state.peakTableSize.load(std::memory_order_acquire), allocatedEntries); - const size_t entriesRemaining = allocatedEntries - completedEntries; - const size_t growThreshold = static_cast( - state.sampleRate * 30.0 / ActiveRecording::PEAK_STRIDE); - if (entriesRemaining <= growThreshold - && allocatedChunks < ActiveRecording::PEAK_MAX_CHUNKS) - { - growFromChunk = allocatedChunks; - growChunkEntries = state.peakChunkEntries; - growEntryFloats = std::min( - state.numChannels, ActiveRecording::PEAK_MAX_CHANNELS) * 2; - } - } - - std::unique_ptr nextChunk; - if (growFromChunk < ActiveRecording::PEAK_MAX_CHUNKS - && growChunkEntries > 0 - && growEntryFloats > 0) + if (samplesPerPixel <= 0 || numPixels <= 0) return peakData; + startSample = std::max(0, startSample); + std::shared_ptr preview; { - nextChunk = std::unique_ptr( - new float[growChunkEntries * static_cast(growEntryFloats)]()); + const juce::ScopedLock lock(previewRegistryLock); + const auto found = recordingPreviews.find(trackId); + if (found == recordingPreviews.end()) return peakData; + preview = found->second; } - - // Brief lock only to publish growth and snapshot the recording entry. - int numChannels = 0; - size_t tableSize = 0; - size_t chunkEntries = 0; - std::array chunkPtrs {}; - - { - const juce::ScopedLock sl(writerLock); - auto it = activeRecordings.find(trackId); - if (it == activeRecordings.end() || !it->second.isActive.load()) - return peakData; - - auto& state = it->second; - const size_t allocatedChunks = state.peakAllocatedChunks.load(std::memory_order_acquire); - if (nextChunk && allocatedChunks == growFromChunk) - { - state.peakChunks[growFromChunk] = std::move(nextChunk); - state.peakChunkPtrs[growFromChunk].store( - state.peakChunks[growFromChunk].get(), std::memory_order_release); - state.peakAllocatedChunks.store(growFromChunk + 1, std::memory_order_release); - } - - numChannels = std::min(state.numChannels, ActiveRecording::PEAK_MAX_CHANNELS); - tableSize = state.peakTableSize.load(std::memory_order_acquire); - chunkEntries = state.peakChunkEntries; - const size_t publishedChunks = state.peakAllocatedChunks.load(std::memory_order_acquire); - for (size_t chunk = 0; chunk < publishedChunks; ++chunk) - chunkPtrs[chunk] = state.peakChunkPtrs[chunk].load(std::memory_order_acquire); - } - // Lock released — read is lock-free (stable pointer, atomic size). - // getRecordingPeaks() and stopAllRecordings() both run on the message thread - // so they are never concurrent — no use-after-free risk. - - startSample = std::max(0, startSample); - if (numChannels == 0 || chunkEntries == 0 || tableSize == 0 - || samplesPerPixel <= 0 || numPixels <= 0) - return peakData; - - // Each table entry covers PEAK_STRIDE samples. - // Use floating-point sample positions to correctly map pixels to peak entries, - // avoiding integer division truncation that caused waveform stretching. - // Previously, entriesPerPixel = samplesPerPixel / stride (integer division) - // would truncate e.g. 441/256 = 1 instead of 1.72, causing each pixel to read - // only one entry and covering just 58% of the actual recording at 100 pps. - const int stride = ActiveRecording::PEAK_STRIDE; - - // Calculate how many complete pixels the peak table data can cover - const double totalSamplesCovered = static_cast(tableSize) * stride; - const double availableSamples = juce::jmax( - 0.0, totalSamplesCovered - static_cast(startSample)); - const int maxPixelsFromData = static_cast(availableSamples / samplesPerPixel); - const int actualPeaks = std::min(numPixels, maxPixelsFromData); - - if (actualPeaks <= 0) - return peakData; - + if (!preview->active.load(std::memory_order_acquire)) return peakData; + + // No writerLock, no disk I/O and no growth allocation on this query path. + // Published entries are immutable. The retained owner makes stop/rollover + // safe even when a non-message-thread caller is still reading this take. + const size_t completed = preview->peakTableSize.load(std::memory_order_acquire); + const size_t chunks = preview->peakAllocatedChunks.load(std::memory_order_acquire); + const size_t tableSize = std::min(completed, chunks * preview->peakChunkEntries); + const auto coveredSamples = static_cast(tableSize) * recordingPeakStride; + if (startSample >= coveredSamples) return peakData; + const auto completePixels = (coveredSamples - startSample) / samplesPerPixel; + const auto entriesPerPixel = (static_cast(samplesPerPixel) + recordingPeakStride - 1) + / recordingPeakStride + ((startSample % recordingPeakStride != 0 || samplesPerPixel % recordingPeakStride != 0) ? 1 : 0); + const auto workLimitedPixels = static_cast(maximumRecordingPeakEntriesPerRequest) / entriesPerPixel; + const int actualPeaks = static_cast(std::min({ completePixels, workLimitedPixels, + static_cast(numPixels), static_cast(maximumRecordingPeakPixels) })); + if (actualPeaks <= 0) return peakData; + + const int numChannels = preview->numChannels; peakData.ensureStorageAllocated(1 + actualPeaks * numChannels * 2); - peakData.add(juce::var(numChannels)); - + peakData.add(numChannels); for (int pixel = 0; pixel < actualPeaks; ++pixel) { - // Map pixel range to sample range, then to peak table entry range - const double pixelStartSample = static_cast(startSample) - + pixel * static_cast(samplesPerPixel); - const double pixelEndSample = pixelStartSample + samplesPerPixel; - const size_t firstEntry = static_cast(pixelStartSample / stride); - const size_t lastEntry = std::min(static_cast(pixelEndSample / stride) + 1, - tableSize); - - for (int ch = 0; ch < numChannels; ++ch) + const auto pixelStart = startSample + static_cast(pixel) * samplesPerPixel; + const auto pixelEnd = pixelStart + samplesPerPixel; + const size_t firstEntry = static_cast(pixelStart / recordingPeakStride); + // Exclusive end: an exactly aligned pixel must not read the next bucket. + const size_t lastEntry = static_cast((pixelEnd + recordingPeakStride - 1) / recordingPeakStride); + for (int channel = 0; channel < numChannels; ++channel) { - float minVal = 0.0f, maxVal = 0.0f; - for (size_t e = firstEntry; e < lastEntry; ++e) + float minimum = 0.0f, maximum = 0.0f; + for (size_t entry = firstEntry; entry < lastEntry; ++entry) { - const size_t chunkIndex = e / chunkEntries; - const size_t entryIndex = e % chunkEntries; - if (chunkIndex >= chunkPtrs.size() || chunkPtrs[chunkIndex] == nullptr) - continue; - const float* entry = chunkPtrs[chunkIndex] - + entryIndex * static_cast(numChannels * 2); - const float eMin = entry[ch * 2]; - const float eMax = entry[ch * 2 + 1]; - if (eMin < minVal) minVal = eMin; - if (eMax > maxVal) maxVal = eMax; + const size_t chunkIndex = entry / preview->peakChunkEntries; + const auto* chunk = preview->peakChunkPtrs[chunkIndex].load(std::memory_order_acquire); + if (chunk == nullptr) continue; + const auto* values = chunk + (entry % preview->peakChunkEntries) * static_cast(numChannels * 2); + minimum = std::min(minimum, values[channel * 2]); + maximum = std::max(maximum, values[channel * 2 + 1]); } - peakData.add(juce::var(minVal)); - peakData.add(juce::var(maxVal)); + peakData.add(minimum); peakData.add(maximum); } } - return peakData; } diff --git a/Source/AudioRecorder.h b/Source/AudioRecorder.h index 22c7c24..5342a6c 100644 --- a/Source/AudioRecorder.h +++ b/Source/AudioRecorder.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "RecordingWriterSafety.h" // Handles recording audio to disk for individual tracks. @@ -51,14 +52,41 @@ class AudioRecorder CompletedRecording& completedPreviousTake); std::vector stopAllRecordings(double currentSampleRate); + // Control-thread maintenance is independent of preview visibility/polling. + void maintainRecordingPreviews(); + static constexpr int recordingPeakStride = 256; + static constexpr int maximumRecordingPeakPixels = 8192; + static constexpr size_t maximumRecordingPeakEntriesPerRequest = 262144; + // Get waveform peaks for a recording currently in progress - // Returns peaks calculated from buffered samples at the requested resolution + // Returns a bounded prefix of complete published pixels at the requested + // resolution. Readers retain preview storage across stop and rollover. juce::var getRecordingPeaks(const juce::String& trackId, int samplesPerPixel, int numPixels, juce::int64 startSample = 0); private: + struct RecordingPreview + { + RecordingPreview(double rate, int channels); + const double sampleRate; + const int numChannels; + const size_t peakChunkEntries; + std::atomic active { true }; + static constexpr int PEAK_MAX_CHANNELS = 2; + static constexpr int PEAK_CHUNK_SECONDS = 120; + static constexpr size_t PEAK_MAX_CHUNKS = 120; // Four hours of preview. + std::array, PEAK_MAX_CHUNKS> peakChunks; + std::array, PEAK_MAX_CHUNKS> peakChunkPtrs {}; + std::atomic peakAllocatedChunks { 0 }; + // Absolute completed-bucket index, including unavailable preview gaps. + // Each published bucket is immutable; readers acquire this size first. + std::atomic peakTableSize { 0 }; + juce::CriticalSection growthLock; // Preview/control threads only. + }; + static void growRecordingPreview(const std::shared_ptr& preview); + struct ActiveRecording { juce::String trackId; @@ -76,34 +104,21 @@ class AudioRecorder int debugLoggedBlocks = 0; #endif - // Incremental peak table for live waveform display. - // - // Instead of storing raw interleaved samples (~46 MB/track for 120 s), - // the audio thread accumulates min/max over PEAK_STRIDE samples and - // appends one entry when the stride is complete. getRecordingPeaks() - // then reads O(pixels) entries instead of O(total samples). - // - // Memory: 120 s × 44100 / 256 ≈ 20 700 entries × 2 ch × 2 values × 4 B ≈ 660 KB/track. - // SPSC: audio thread writes (atomic size), message thread reads lock-free. - static constexpr int PEAK_STRIDE = 256; // Samples per peak entry - static constexpr int PEAK_MAX_CHANNELS = 2; - static constexpr int PEAK_CHUNK_SECONDS = 120; - static constexpr size_t PEAK_MAX_CHUNKS = 120; // Four hours of live preview - std::array, PEAK_MAX_CHUNKS> peakChunks; - std::array, PEAK_MAX_CHUNKS> peakChunkPtrs {}; - std::atomic peakAllocatedChunks { 0 }; - size_t peakChunkEntries = 0; - std::unique_ptr peakTable; // [min_ch0, max_ch0, min_ch1, max_ch1] × N - std::atomic peakTableSize { 0 }; // Completed entries (atomic) - size_t peakTableCapacity = 0; + std::shared_ptr preview; // Accumulator — audio thread only (never touched from message thread) - float accumMin[PEAK_MAX_CHANNELS] { 0.0f, 0.0f }; - float accumMax[PEAK_MAX_CHANNELS] { 0.0f, 0.0f }; + float accumMin[RecordingPreview::PEAK_MAX_CHANNELS] { 0.0f, 0.0f }; + float accumMax[RecordingPreview::PEAK_MAX_CHANNELS] { 0.0f, 0.0f }; int accumCount = 0; }; std::map activeRecordings; + // Neither preview nor status polling ever acquires writerLock. Shared + // preview ownership keeps immutable published buckets alive for readers. + std::map> recordingPreviews; + juce::CriticalSection previewRegistryLock; + juce::CriticalSection statusLock; + juce::CriticalSection controlLock; // Serializes start/stop/rollover publication. std::vector> pendingWriteStatuses; bool startRecordingInternal(const juce::String& trackId, const juce::File& file, @@ -120,5 +135,7 @@ class AudioRecorder std::atomic writeLockMissCount { 0 }; std::atomic writerBufferOverflowCount { 0 }; + friend void runRecordingRecoveryRegression(const juce::File&, + const std::function&); JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioRecorder) }; diff --git a/Source/BuiltInAdditionalReverbs.h b/Source/BuiltInAdditionalReverbs.h index c7cccd6..4a90a6c 100644 --- a/Source/BuiltInAdditionalReverbs.h +++ b/Source/BuiltInAdditionalReverbs.h @@ -1,5 +1,8 @@ #pragma once +#include "BuiltInReverbDamping.h" +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbPitchVoice.h" +#include "BuiltInShimmerScheduling.h" #include "BuiltInNonlinearTail.h" #include "BuiltInReverbSpillover.h" @@ -17,10 +20,13 @@ class BuiltInAdditionalReverbs bool hold = false, infiniteInput = false; float nonlinearModulation = 0, nonlinearRate = .7f; bool nonlinearHold=false; + bool spreadShimmer=false; // Saved opt-in scheduling; Original remains false. + bool correctedControls=false; }; void prepare(double sampleRate, int selected) { rate = static_cast(sampleRate); + for(auto& tone:dampingRamp)tone.reset(sampleRate,.05); pre.assign(static_cast(std::ceil(rate * 0.51f)) + 2, {}); for(auto& history:kindPre)history.assign(pre.size(),{}); for(auto& delay:kindPredelay)delay.reset(sampleRate,.05); @@ -60,6 +66,15 @@ class BuiltInAdditionalReverbs predelay.reset(sampleRate, .05); predelay.setCurrentAndTargetValue(0); reset(); + // Configure and prime once off the callback, after reset. Original-only + // resets below never clear or prime this otherwise inactive FFT bank. + spreadSpectralVoices[0].prepare(sampleRate, 0); + spreadSpectralVoices[1].prepare(sampleRate, 2); + spreadInputSpectralVoices[0].prepare(sampleRate, 1); + spreadInputSpectralVoices[1].prepare(sampleRate, 3); + spreadNeedsReset = false; + pitchScheduling.prepare(sampleRate, spreadSpectralVoices[0].latencySamples(), + spreadInputSpectralVoices[1].primingSamples(),spreadSpectralVoices[0].resetSamples()); } void reset() { @@ -72,8 +87,13 @@ class BuiltInAdditionalReverbs for (auto& shift : shifter) shift.reset(); for (auto& voice : spectralVoices) voice.reset(); for (auto& voice : inputSpectralVoices) voice.reset(); + // Defer any Spread clear/prime to the staged transition before reuse. + spreadNeedsReset = true; + pitchScheduling.reset(); for (auto& voice : inputShifter) voice.reset(); - inputDrainRemaining = 0; nonlinearTail.reset(); nonlinearReturned.fill(0); lateWasActive = false; + inputDrainRemaining = 0; nonlinearTail.reset(); nonlinearReturned.fill(0); lateWasActive = false;nonlinearHoldBudget.reset();nonlinearReadoutBudget.reset(); + tankPitchSuspended = false; tankPitchWarmup = 0; tankPitchFrames = 0; + requestedTankRoute = true; requestedInputRoute = false; nonlinearPhase = 0; positions.fill(0); springPositions.fill(0); dampingState.fill(0); for (auto& state : dispersion) state.fill(0); @@ -82,6 +102,18 @@ class BuiltInAdditionalReverbs shifted.fill(0); prePosition = 0; nonlinearPosition = 0; settingsInitialized = false; spectralDrainRemaining = 0; drainRemaining.fill(0); processedFrames.fill(0); validLines.fill(0); validSpring.fill(0); validNonlinear=0; validPre=0; } + // Explicit host resets in the current app run while callback processing is + // stopped. Restore the deterministic prepared Spread phase there, rather + // than routing a freshly reset Spread state through an Original warmup. + // Callback-side resetTailState keeps the lazy reset above; Original-only + // explicit resets do not call this additional clear/prime operation. + void resetShimmerSchedulingHistory() + { + for (auto& voice : spreadSpectralVoices) voice.reset(); + for (auto& voice : spreadInputSpectralVoices) voice.reset(); + spreadNeedsReset = false; + pitchScheduling.reset(); + } void configure(int selected, const Settings& settings, bool spillover=false) { retainTails=spillover; @@ -118,6 +150,7 @@ class BuiltInAdditionalReverbs retirement.configure(kind,selected==static_cast(kind)+4,retainTails,drainRemaining[kind]>0); } const auto& shimmerSettings=settingsFor(1);const auto& nonlinearSettings=settingsFor(2); + pitchScheduling.configure(shimmerSettings.spreadShimmer, !spreadNeedsReset); for (size_t i = 0; i < weights.size(); ++i) weights[i].setTargetValue(selected == static_cast(i) + 4 ? 1.0f : 0.0f); const float targetPreDelay = juce::jlimit(0.0f, .5f * rate, values.preDelay * .001f * rate); if (!settingsInitialized) predelay.setCurrentAndTargetValue(targetPreDelay); @@ -135,8 +168,13 @@ class BuiltInAdditionalReverbs setSmooth(shimmerBlend, juce::jlimit(0.0f, .45f, shimmerSettings.shimmer * .45f)); setSmooth(spectralWeight, shimmerSettings.voiceEngine >= .5f ? 1.0f : 0.0f); const int route = juce::jlimit(0, 2, juce::roundToInt(finite(shimmerSettings.shimmerRoute, 0))); - setSmooth(inputRoute, route > 0 ? 1.0f : 0.0f); - setSmooth(tankRoute, route == 1 ? 0.0f : 1.0f); + requestedInputRoute = route > 0; requestedTankRoute = route != 1; + // Original-only processing never suspends and retains the historical + // 50 ms route ramps. A sleeping Spread tank must refill first: keep the + // audible Input path until the pitch return has fresh history. + const bool deferTankRoute = requestedTankRoute && (tankPitchSuspended || tankPitchWarmup > 0); + if (!deferTankRoute) setSmooth(inputRoute, requestedInputRoute ? 1.0f : 0.0f); + setSmooth(tankRoute, requestedTankRoute && !deferTankRoute ? 1.0f : 0.0f); setSmooth(nonlinearReturn, nonlinearSettings.nonlinearFeedback); setSmooth(nonlinearLateLevel, nonlinearSettings.lateLevel); setSmooth(nonlinearSmear, nonlinearSettings.nonlinearDiffusion); @@ -145,7 +183,7 @@ class BuiltInAdditionalReverbs setSmooth(nonlinearHoldSend,holdNonlinear&&!nonlinearSettings.infiniteInput?0.0f:1.0f); setSmooth(nonlinearDepth, holdNonlinear?0.0f:nonlinearSettings.nonlinearModulation * rate * .002f); setSmooth(nonlinearRate, nonlinearSettings.nonlinearRate); - if (selected == 6 || drainRemaining[2] > 0) nonlinearTail.configure(nonlinearSettings.lateDecay, nonlinearSettings.damping,holdNonlinear); + if (selected == 6 || drainRemaining[2] > 0) nonlinearTail.configure(nonlinearSettings.lateDecay, nonlinearSettings.damping,holdNonlinear,nonlinearSettings.correctedControls); const int shape = juce::roundToInt(nonlinearSettings.shape); // Normalize every envelope to the same tap energy as the legacy Ramp. // This prevents a shape switch from being merely an output-gain change. @@ -181,7 +219,6 @@ class BuiltInAdditionalReverbs reflectionGainBound = std::sqrt(48.0f * legacyEnergy) * .035f; previousShape = shape; } - settingsInitialized = true; const float decay = juce::jlimit(.1f, 20.0f, values.decay); if(selected>=4&&selected<=6) { @@ -200,10 +237,13 @@ class BuiltInAdditionalReverbs lowCoefficient[kind] = 1.0f - std::exp(-juce::MathConstants::twoPi * juce::jlimit(1000.0f, rate * .45f, settingsForKind.highCut) / rate); highCoefficient[kind] = 1.0f - std::exp(-juce::MathConstants::twoPi * juce::jlimit(20.0f, 500.0f, settingsForKind.lowCut) / rate); dampingCoefficient[kind] = juce::jlimit(.03f, .95f, .95f - settingsForKind.damping * .9f); + setSmooth(dampingRamp[kind], BuiltInReverbDamping::alpha(dampingCoefficient[kind],rate)); } + settingsInitialized = true; } - std::array process(float left, float right) + std::array process(float left, float right, float returnBlend = 0) { + for(size_t kind=0;kind<3;++kind) { const float next=dampingRamp[kind].getNextValue(); if(settingsFor(kind).correctedControls) dampingCoefficient[kind]=next; } if (pre.empty()) return {}; if(isDormant()) return {}; left = std::isfinite(left) ? juce::jlimit(-16.0f, 16.0f, left) : 0.0f; @@ -256,6 +296,61 @@ class BuiltInAdditionalReverbs if(drainRemaining[1]>0) { ++processedFrames[1]; + const int resetPitchVoice = pitchScheduling.advance(); + if (resetPitchVoice >= 0) + { + const auto slot = static_cast(resetPitchVoice % 2); + if (pitchScheduling.resetSpreadBank()) + { + if (resetPitchVoice < 2) spreadSpectralVoices[slot].beginReset(); + else spreadInputSpectralVoices[slot].beginReset(); + if (resetPitchVoice == 3) spreadNeedsReset = false; + } + else + { + if (resetPitchVoice < 2) spectralVoices[slot].beginReset(); + else inputSpectralVoices[slot].beginReset(); + } + } + if(pitchScheduling.clearingTargetBank()) + { + if(pitchScheduling.resetSpreadBank()) + { + for(auto& voice:spreadSpectralVoices)voice.resetStep(); + for(auto& voice:spreadInputSpectralVoices)voice.resetStep(); + } + else + { + for(auto& voice:spectralVoices)voice.resetStep(); + for(auto& voice:inputSpectralVoices)voice.resetStep(); + } + } + if (pitchScheduling.primeSpreadBank()) + { + for (auto& voice : spreadSpectralVoices) voice.primeStep(); + for (auto& voice : spreadInputSpectralVoices) voice.primeStep(); + } + const bool runOriginalPitch = pitchScheduling.processOriginal(); + const bool runSpreadPitch = pitchScheduling.processSpread(); + const bool canSuspendTank = !requestedTankRoute && tankRoute.getCurrentValue() == 0 + && !tankRoute.isSmoothing() && pitchScheduling.isSettled() && pitchScheduling.isSpread(); + if (canSuspendTank) + { + tankPitchSuspended = true; tankPitchWarmup = 0; + } + else if (tankPitchSuspended) + { + tankPitchSuspended = false; + // More than a complete analysis/overlap history. Refill by processing + // real samples over real time, without reset, replay loops or stale + // output reaching the feedback path. Also covers a scheduling change. + tankPitchWarmup = static_cast(rate * .3f) + 64; + } + else if (tankPitchWarmup > 0 && --tankPitchWarmup == 0) + { + inputRoute.setTargetValue(requestedInputRoute ? 1.0f : 0.0f); + tankRoute.setTargetValue(requestedTankRoute ? 1.0f : 0.0f); + } std::array taps {}; float sum = 0; for (size_t i = 0; i < lines.size(); ++i) { taps[i] = validLines[i]>=lines[i].size()?lines[i][positions[i]]:0.0f; sum += taps[i]; } @@ -274,8 +369,17 @@ class BuiltInAdditionalReverbs { --inputDrainRemaining; const float leftInput = feedInput ? (retainTails?kindInput[1][0]:input[0]) : 0, rightInput = feedInput ? (retainTails?kindInput[1][1]:input[1]) : 0; - const auto firstVoice = inputSpectralVoices[0].process(leftInput, rightInput, ratioA); - const auto secondVoice = inputSpectralVoices[1].process(leftInput, rightInput, ratioB); + std::array firstVoice{}, secondVoice{}; + if (runOriginalPitch) + { + firstVoice = inputSpectralVoices[0].process(leftInput, rightInput, ratioA); + secondVoice = inputSpectralVoices[1].process(leftInput, rightInput, ratioB); + } + if (runSpreadPitch) + { + firstVoice = pitchScheduling.mix(firstVoice, spreadInputSpectralVoices[0].process(leftInput, rightInput, ratioA)); + secondVoice = pitchScheduling.mix(secondVoice, spreadInputSpectralVoices[1].process(leftInput, rightInput, ratioB)); + } for (size_t ch = 0; ch < 2; ++ch) inputShift[ch] = inputShifter[ch].processSample(ch == 0 ? leftInput : rightInput) * (1 - spectralMix) + (firstVoice[ch] * (1 - blend) + secondVoice[ch] * blend) * spectralMix; @@ -302,12 +406,21 @@ class BuiltInAdditionalReverbs // voices, so returning to Shimmer cannot replay frozen old input. const bool feedSpectral = spectralMix > 0 && (weights[1].getCurrentValue() > 0 || weights[1].getTargetValue() > 0 || (retainTails&&retirement.audibleRetiring(1))); if (feedSpectral) spectralDrainRemaining = static_cast(rate * .3f); - if (spectralDrainRemaining > 0) + if (spectralDrainRemaining > 0 && !tankPitchSuspended) { + ++tankPitchFrames; --spectralDrainRemaining; const float inputL = feedSpectral ? wet[1][0] : 0, inputR = feedSpectral ? wet[1][1] : 0; - voiceA = spectralVoices[0].process(inputL, inputR, ratioA); - voiceB = spectralVoices[1].process(inputL, inputR, ratioB); + if (runOriginalPitch) + { + voiceA = spectralVoices[0].process(inputL, inputR, ratioA); + voiceB = spectralVoices[1].process(inputL, inputR, ratioB); + } + if (runSpreadPitch) + { + voiceA = pitchScheduling.mix(voiceA, spreadSpectralVoices[0].process(inputL, inputR, ratioA)); + voiceB = pitchScheduling.mix(voiceB, spreadSpectralVoices[1].process(inputL, inputR, ratioB)); + } } for (size_t ch = 0; ch < 2; ++ch) { @@ -323,6 +436,14 @@ class BuiltInAdditionalReverbs ++processedFrames[2]; const float returnGain = nonlinearReturn.getNextValue() / juce::jmax(1.0f, reflectionGainBound); const float held=nonlinearHoldWeight.getNextValue(),send=nonlinearHoldSend.getNextValue(); + // The 48-tap generator and late FDN share a readout budget. Each tap + // sum is bounded by reflectionGainBound and each late output by + // 4*.35 times the maximum stored sample. Reserve a further factor of + // two for the output high-pass transient. The final output guard is + // therefore not the normal control law for accumulating Infinite. + const bool infinite=held>0&&settingsFor(2).infiniteInput; + const float infiniteLimit=infinite?1.0f/(BuiltInReverbReturnLevels::gain(6,returnBlend) + *(reflectionGainBound+1.4f*juce::jmax(nonlinearLateLevel.getCurrentValue(),nonlinearLateLevel.getTargetValue()))):0.0f; auto nonlinearInput = nonlinearTail.diffuse({ kindInput[2][0]*send + nonlinearReturned[0] * returnGain*(1-held), kindInput[2][1]*send + nonlinearReturned[1] * returnGain*(1-held) }, nonlinearSmear.getNextValue()); @@ -332,7 +453,9 @@ class BuiltInAdditionalReverbs { const auto loopLength=static_cast(juce::roundToInt(duration)); const auto loop=validNonlinear>=loopLength?nonlinear[(nonlinearPosition+nonlinear.size()-loopLength)%nonlinear.size()]:std::array{}; - for(size_t ch=0;ch<2;++ch){nonlinearInput[ch]*=send;next[ch]=juce::jlimit(-4.0f,4.0f,nonlinearInput[ch]+held*loop[ch]);} + for(size_t ch=0;ch<2;++ch){nonlinearInput[ch]*=send;next[ch]=nonlinearInput[ch]+held*loop[ch];} + if(infinite)nonlinearHoldBudget.process(next,infiniteLimit); + for(auto& sample:next)sample=juce::jlimit(-4.0f,4.0f,sample); } nonlinear[nonlinearPosition] = next; validNonlinear=juce::jmin(nonlinear.size(),validNonlinear+1); @@ -372,7 +495,7 @@ class BuiltInAdditionalReverbs { auto excitation=wet[2]; if(held>0)for(size_t ch=0;ch<2;++ch)excitation[ch]=held>=1?nonlinearInput[ch]:excitation[ch]+held*(nonlinearInput[ch]-excitation[ch]); - const auto tail = nonlinearTail.process(excitation, depthSamples, phaseSine, phaseCosine); + const auto tail = nonlinearTail.process(excitation, depthSamples, phaseSine, phaseCosine,infiniteLimit); for (size_t ch = 0; ch < 2; ++ch) wet[2][ch] += tail[ch] * lateGain; lateWasActive = true; } @@ -386,16 +509,22 @@ class BuiltInAdditionalReverbs const float wetWeight=retirement.next(kind); output[2] += weight; if(drainRemaining[kind]==0) continue; + const float returnWeight=BuiltInReverbReturnLevels::weighted(retainTails?wetWeight:weight,4+static_cast(kind),returnBlend); for (size_t ch = 0; ch < 2; ++ch) { highState[kind][ch] += highCoefficient[kind] * (wet[kind][ch] - highState[kind][ch]); lowState[kind][ch] += lowCoefficient[kind] * (wet[kind][ch] - highState[kind][ch] - lowState[kind][ch]); - if(!retainTails)output[ch] += lowState[kind][ch] * weight; } + auto readout=lowState[kind]; + // A live return-gain change can expose older, louder history + // before the bounded writes have traversed the complete loop. + if(kind==2&&nonlinearHoldWeight.getCurrentValue()>0&&settingsFor(2).infiniteInput) + nonlinearReadoutBudget.process(readout,1.0f/BuiltInReverbReturnLevels::gain(6,returnBlend)); + if(!retainTails)for(size_t ch=0;ch<2;++ch)output[ch]+=readout[ch]*returnWeight; if(retainTails) { - const float mid=(lowState[kind][0]+lowState[kind][1])*.5f,side=(lowState[kind][0]-lowState[kind][1])*.5f*settingsFor(kind).width; - output[0]+=(mid+side)*wetWeight;output[1]+=(mid-side)*wetWeight; + const float mid=(readout[0]+readout[1])*.5f,side=(readout[0]-readout[1])*.5f*settingsFor(kind).width; + output[0]+=(mid+side)*returnWeight;output[1]+=(mid-side)*returnWeight; } if(--drainRemaining[kind]==0) { @@ -437,8 +566,19 @@ class BuiltInAdditionalReverbs // Audio-thread-owned diagnostics; inspect only while processing is stopped. bool isDormant() const noexcept { return drainRemaining[0]==0&&drainRemaining[1]==0&&drainRemaining[2]==0; } std::array processingFrames() const noexcept { return processedFrames; } + bool shimmerSchedulingSettled() const noexcept { return pitchScheduling.isSettled(); } + bool shimmerSchedulingSpread() const noexcept { return pitchScheduling.isSpread(); } + bool shimmerTankPitchSuspended() const noexcept { return tankPitchSuspended; } + int shimmerTankPitchWarmupRemaining() const noexcept { return tankPitchWarmup; } + float shimmerTankRouteGain() const noexcept { return tankRoute.getCurrentValue(); } + std::uint64_t shimmerTankPitchProcessedFrames() const noexcept { return tankPitchFrames; } + int shimmerSchedulingExtraLatencySamples() const noexcept { return spreadSpectralVoices[0].extraLatencySamples(); } + int shimmerSchedulingWarmupSamples() const noexcept { return spreadSpectralVoices[0].latencySamples(); } + const BuiltInInfiniteHoldBudget& nonlinearInfiniteBudget()const noexcept{return nonlinearHoldBudget;} + const BuiltInInfiniteHoldBudget& nonlinearLateInfiniteBudget()const noexcept{return nonlinearTail.infiniteBudget();} private: BuiltInNonlinearTail nonlinearTail; + BuiltInInfiniteHoldBudget nonlinearHoldBudget,nonlinearReadoutBudget; std::array nonlinearReturned {}; float reflectionGainBound = 1; bool lateWasActive = false; @@ -447,6 +587,7 @@ class BuiltInAdditionalReverbs double nonlinearPhase = 0; std::array, 48> reflectionPhase {}; float rate = 44100; + std::array,3> dampingRamp; std::array lowCoefficient {1,1,1},highCoefficient {},dampingCoefficient {.5f,.5f,.5f}; Settings values {}; std::array kindSettings {}; @@ -477,6 +618,12 @@ class BuiltInAdditionalReverbs std::array, 3> highState {}, lowState {}; std::array shifter, inputShifter; std::array spectralVoices, inputSpectralVoices; + std::array spreadSpectralVoices, spreadInputSpectralVoices; + BuiltInShimmerScheduling pitchScheduling; + bool tankPitchSuspended = false, requestedTankRoute = true, requestedInputRoute = false; + int tankPitchWarmup = 0; + std::uint64_t tankPitchFrames = 0; + bool spreadNeedsReset = true; std::array, 2> pitchRatio; juce::SmoothedValue voiceMix, shimmerBlend, spectralWeight, inputRoute, tankRoute, holdWeight, holdSend; std::array, 48> reflectionGain; diff --git a/Source/BuiltInAmbientReverb.h b/Source/BuiltInAmbientReverb.h index ef75ceb..5bf539f 100644 --- a/Source/BuiltInAmbientReverb.h +++ b/Source/BuiltInAmbientReverb.h @@ -1,13 +1,27 @@ #pragma once #include +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbSpillover.h" +#include "BuiltInReverbSparseDiffusion.h" +#include "BuiltInInfiniteHoldBudget.h" +#include // Four original ambient networks with independent excitation and hold policies. class BuiltInAmbientReverb { public: using Pair=std::array; - struct Settings {float decay=4,size=.5f,damping=.5f,diffusion=.5f,predelay=0,lowCut=20,highCut=20000,width=1,rise=.5f,swellMode=0,length=.8f,feedback=.3f,depth=.3f,rate=.2f,vowel=0,resonance=1,hold=0,bass=1;}; + struct Settings + { + float decay=4,size=.5f,damping=.5f,diffusion=.5f,predelay=0,lowCut=20,highCut=20000,width=1,rise=.5f,swellMode=0,length=.8f,feedback=.3f,depth=.3f,rate=.2f,vowel=0,resonance=1,hold=0,bass=1; + bool refined=false; + float body=0; + bool operator==(const Settings& other) const noexcept + { + return std::tie(decay,size,damping,diffusion,predelay,lowCut,highCut,width,rise,swellMode,length,feedback,depth,rate,vowel,resonance,hold,bass,refined,body) + == std::tie(other.decay,other.size,other.damping,other.diffusion,other.predelay,other.lowCut,other.highCut,other.width,other.rise,other.swellMode,other.length,other.feedback,other.depth,other.rate,other.vowel,other.resonance,other.hold,other.bass,other.refined,other.body); + } + }; private: struct Line { @@ -27,21 +41,30 @@ class BuiltInAmbientReverb std::array tank;std::array,2> diffusers;std::array pre; std::array,16> lengths,losses,bassLosses; juce::SmoothedValue decay,dampPole,dampMix,diffusion,preTime,width,rise,length,bloomFeedback,depth,speed,hold,send,lowPole,highPole,lowMix,highMix,drySwell; + juce::SmoothedValue body; std::array dampState{},bassState{};Pair bloomMemory{},lowState{},highState{}; std::array,3>,2> formantState{}; std::array formantG{},formantK{},targetG{},targetK{};double fs=48000,phase=0,formantSmoothing=0;float ramp=0,rampGain=0,bassPole=0,rampPole=0;int silence=0,mode=0,formantCountdown=0,vowelA=0,vowelB=1;juce::uint32 random=1;bool initialized=false,triggered=false,started=false,formantInitialized=false;Settings settings;juce::uint64 frames=0; + BuiltInReverbSparseDiffusion readoutDiffusion; + BuiltInInfiniteHoldBudget infiniteWriteBudget,infiniteReadoutBudget; void prepare(double rate,int selected) { fs=rate;mode=selected;bassPole=static_cast(1-std::exp(-juce::MathConstants::twoPi*250/fs));rampPole=static_cast(1-std::exp(-1/(fs*.003)));formantSmoothing=1-std::exp(-1/(fs*.02));for(auto& line:tank)line.prepare(static_cast(fs*.45));for(auto& channel:diffusers)for(auto& line:channel)line.prepare(static_cast(fs*.45));for(auto& line:pre)line.prepare(static_cast(fs*.51)); for(auto* value:{&decay,&dampPole,&dampMix,&diffusion,&preTime,&width,&rise,&length,&bloomFeedback,&depth,&speed,&hold,&send,&lowPole,&highPole,&lowMix,&highMix,&drySwell})value->reset(fs,.05); for(auto* values:{&lengths,&losses,&bassLosses})for(auto& value:*values)value.reset(fs,.1); + readoutDiffusion.prepare(fs); + body.reset(fs,.02); reset(); frames=0; } - void reset(){for(auto& line:tank)line.reset();for(auto& channel:diffusers)for(auto& line:channel)line.reset();for(auto& line:pre)line.reset();dampState={};bassState={};bloomMemory={};lowState={};highState={};formantState={};phase=0;ramp=rampGain=0;silence=0;triggered=started=formantInitialized=false;initialized=false;formantCountdown=0;random=1;vowelA=0;vowelB=1;} + void reset(){for(auto& line:tank)line.reset();for(auto& channel:diffusers)for(auto& line:channel)line.reset();for(auto& line:pre)line.reset();dampState={};bassState={};bloomMemory={};lowState={};highState={};formantState={};phase=0;ramp=rampGain=0;silence=0;triggered=started=formantInitialized=false;initialized=false;formantCountdown=0;random=1;vowelA=0;vowelB=1;readoutDiffusion.reset();infiniteWriteBudget.reset();infiniteReadoutBudget.reset();} void configure(Settings next) { + if(initialized&&settings==next)return; settings=next;const auto set=[&](auto& value,float target){if(initialized)value.setTargetValue(target);else value.setCurrentAndTargetValue(target);}; + // Cloud only: the other ambient voices retain their own onset laws. + readoutDiffusion.configure(mode==2&&settings.refined); + set(body,mode==3?settings.body:0.0f); set(decay,settings.decay);set(dampPole,static_cast(1-std::exp(-juce::MathConstants::twoPi*(18000*std::pow(1.0/36,settings.damping))/fs)));set(dampMix,settings.damping>0?1.0f:0);set(diffusion,settings.diffusion);set(preTime,static_cast(settings.predelay*.001*fs));set(width,settings.width);set(rise,settings.rise);set(length,settings.length);set(bloomFeedback,settings.feedback);set(depth,settings.depth);set(speed,settings.rate);set(hold,settings.hold>=.5f?1.0f:0);set(send,settings.hold>=1.5f?0.0f:1.0f);set(drySwell,mode==0&&settings.swellMode>=.5f?1.0f:0); set(lowPole,static_cast(1-std::exp(-juce::MathConstants::twoPi*settings.lowCut/fs)));set(highPole,static_cast(1-std::exp(-juce::MathConstants::twoPi*settings.highCut/fs)));set(lowMix,settings.lowCut>20?1.0f:0);set(highMix,settings.highCut<20000?1.0f:0); constexpr std::array times{29.7f,31.1f,37.1f,41.3f,43.7f,47.9f,53.3f,59.3f,61.7f,67.9f,73.1f,79.7f,83.3f,89.9f,97.1f,103.3f}; @@ -63,7 +86,7 @@ class BuiltInAmbientReverb for(size_t ch=0;ch<2;++ch)for(size_t band=0;band<3;++band){auto& state=formantState[ch][band];const double g=formantG[band],k=formantK[band],h=1/(1+g*(g+k)),v1=h*(state[0]+g*(input[ch]-state[1])),v2=state[1]+g*v1;state[0]=2*v1-state[0];state[1]=2*v2-state[1];output[ch]+=static_cast(v1*k*levels[band]*1.5);} return output; } - std::array process(Pair source,bool active) + std::array process(Pair source,bool active,float returnGain) { ++frames;const float held=hold.getNextValue(),excitation=send.getNextValue(),diff=diffusion.getNextValue(),motion=depth.getNextValue()*(1-held),frequency=speed.getNextValue(),build=length.getNextValue(),fb=bloomFeedback.getNextValue(),predelay=preTime.getNextValue(),riseTime=rise.getNextValue(),inputSwell=drySwell.getNextValue(); decay.skip(1);const float envelope=juce::jmax(std::abs(source[0]),std::abs(source[1])); @@ -76,22 +99,41 @@ class BuiltInAmbientReverb for(size_t ch=0;ch<2;++ch) { float signal=input[ch]+(mode==1?bloomMemory[ch]*fb:0); - for(int stage=0;stage(stage);const float scale=mode==1?build:mode==2?.6f+settings.size*1.2f:.25f;const float base=static_cast(fs)*diffuserTimes[index]*scale;const float mod=juce::jmin(base*.45f,motion*static_cast(fs)*.004f)*static_cast(std::sin(juce::MathConstants::twoPi*(phase+stage*.173+ch*.25)));const float delayed=diffusers[ch][index].allpass(signal,base+mod,-.6f);signal+=diff*(delayed-signal);} + for(int stage=0;stage(stage);const float scale=mode==1?build:mode==2?.6f+settings.size*1.2f:.25f;const float base=static_cast(fs)*diffuserTimes[index]*scale;const float mod=motion==0?0:juce::jmin(base*.45f,motion*static_cast(fs)*.004f)*static_cast(std::sin(juce::MathConstants::twoPi*(phase+stage*.173+ch*.25)));const float delayed=diffusers[ch][index].allpass(signal,base+mod,-.6f);signal+=diff*(delayed-signal);} bloomMemory[ch]=juce::jlimit(-2.0f,2.0f,signal);input[ch]=signal*excitation; } std::array values{};Pair output{};const float damp=dampPole.getNextValue(),dampAmount=dampMix.getNextValue()*(1-held); for(size_t i=0;i<16;++i) { - const float delay=lengths[i].getNextValue(),mod=mode==2?0:motion*static_cast(fs)*.002f*static_cast(std::sin(juce::MathConstants::twoPi*(phase+i*.137))); + const float delay=lengths[i].getNextValue(),mod=mode==2||motion==0?0:motion*static_cast(fs)*.002f*static_cast(std::sin(juce::MathConstants::twoPi*(phase+i*.137))); const float sample=tank[i].read((held>=.99999f?std::round(delay):delay)+mod);dampState[i]+=damp*(sample-dampState[i]);const float filtered=sample+dampAmount*(dampState[i]-sample);bassState[i]+=bassPole*(filtered-bassState[i]);const float mainLoss=losses[i].getNextValue(),lowLoss=bassLosses[i].getNextValue();values[i]=(filtered-bassState[i])*(mainLoss+held*(1-mainLoss))+bassState[i]*(lowLoss+held*(1-lowLoss)); output[0]+=sample*(i%2?-.15f:.15f);output[1]+=sample*(i%3?-.15f:.15f); } for(size_t stride=1;stride<16;stride*=2)for(size_t base=0;base<16;base+=stride*2)for(size_t offset=0;offset writes{}; + for(size_t i=0;i<16;++i)writes[i]=values[(i*5+1)%16]*.25f+(input[0]+(i%2?-input[1]:input[1]))*.0875f; + // Rising's readout is a sum of 16 taps at .15 each. This cap + // reserves the output high-pass transient while retaining new + // notes in Infinite. Finite and input-rejecting Freeze are exact + // bypasses; other ambient topologies keep their own contracts. + if(mode==0&&held>0&&settings.hold>=.5f&&settings.hold<1.5f) + infiniteWriteBudget.process(writes,1.0f/(2.4f*returnGain)); + for(size_t i=0;i<16;++i)tank[i].write(juce::jlimit(-2.0f,2.0f,writes[i])); phase+=frequency/fs;if(phase>=1){phase-=1;vowelA=vowelB;random=random*1664525u+1013904223u;vowelB=static_cast((random>>16)%3);} - if(mode==3)output=vowels(output,motion); + if(mode==3) + { + const Pair unfiltered=output; + output=vowels(output,motion); + const float amount=body.getNextValue(); + // Feedforward bed only: retain the normalized resonant peaks and + // never put the added broadband signal into the held feedback tank. + if(amount!=0)for(size_t ch=0;ch<2;++ch)output[ch]+=amount*unfiltered[ch]; + } const float lp=highPole.getNextValue(),hp=lowPole.getNextValue(),loMix=lowMix.getNextValue(),hiMix=highMix.getNextValue(); for(size_t ch=0;ch<2;++ch){lowState[ch]+=hp*(output[ch]-lowState[ch]);output[ch]-=loMix*lowState[ch];highState[ch]+=lp*(output[ch]-highState[ch]);output[ch]+=hiMix*(highState[ch]-output[ch]);if(mode==0)output[ch]*=1+(1-inputSwell)*(rampGain-1);} + output=readoutDiffusion.process(output); + if(mode==0&&held>0&&settings.hold>=.5f&&settings.hold<1.5f) + infiniteReadoutBudget.process(output,1.0f/returnGain); const float mid=(output[0]+output[1])*.5f,side=(output[0]-output[1])*.5f*width.getNextValue();return {mid+side,mid-side,source[0]*(1+inputSwell*(rampGain-1)),source[1]*(1+inputSwell*(rampGain-1))}; } }; @@ -102,7 +144,7 @@ class BuiltInAmbientReverb void configure(int selected,Settings settings,bool retainTails=false) { const auto safe=[](float x,float lo,float hi,float fallback){return std::isfinite(x)?juce::jlimit(lo,hi,x):fallback;}; - settings.decay=safe(settings.decay,.1f,50,4);settings.size=safe(settings.size,0,1,.5f);settings.damping=safe(settings.damping,0,1,.5f);settings.diffusion=safe(settings.diffusion,0,1,.5f);settings.predelay=safe(settings.predelay,0,500,0);settings.lowCut=safe(settings.lowCut,20,500,20);settings.highCut=safe(settings.highCut,1000,20000,20000);settings.width=safe(settings.width,0,1,1);settings.rise=safe(settings.rise,.01f,5,.5f);settings.swellMode=safe(settings.swellMode,0,1,0);settings.length=safe(settings.length,.02f,2,.8f);settings.feedback=safe(settings.feedback,0,.95f,.3f);settings.depth=safe(settings.depth,0,1,.3f);settings.rate=safe(settings.rate,.05f,2,.2f);settings.vowel=safe(settings.vowel,0,6,0);settings.resonance=safe(settings.resonance,0,2,1);settings.hold=safe(settings.hold,0,2,0);settings.bass=safe(settings.bass,.5f,2,1); + settings.decay=safe(settings.decay,.1f,50,4);settings.size=safe(settings.size,0,1,.5f);settings.damping=safe(settings.damping,0,1,.5f);settings.diffusion=safe(settings.diffusion,0,1,.5f);settings.predelay=safe(settings.predelay,0,500,0);settings.lowCut=safe(settings.lowCut,20,500,20);settings.highCut=safe(settings.highCut,1000,20000,20000);settings.width=safe(settings.width,0,1,1);settings.rise=safe(settings.rise,.01f,5,.5f);settings.swellMode=safe(settings.swellMode,0,1,0);settings.length=safe(settings.length,.02f,2,.8f);settings.feedback=safe(settings.feedback,0,.95f,.3f);settings.depth=safe(settings.depth,0,1,.3f);settings.rate=safe(settings.rate,.05f,2,.2f);settings.vowel=safe(settings.vowel,0,6,0);settings.resonance=safe(settings.resonance,0,2,1);settings.hold=safe(settings.hold,0,2,0);settings.bass=safe(settings.bass,.5f,2,1);settings.body=safe(settings.body,0,1,0); for(size_t i=0;i<4;++i) { const double build=i==1?.634*settings.length*(1+(settings.feedback>0?std::log(.001)/std::log(settings.feedback):0)):i==2?3.0:0;const double tail=settings.hold>=.5f?120:settings.decay*juce::jmax(1.0f,settings.bass)*1.5+2+build+settings.predelay*.001; @@ -112,11 +154,18 @@ class BuiltInAmbientReverb else if(retainTails&&running[i]&&engines[i].settings.hold>=.5f){auto retiring=engines[i].settings;retiring.hold=0;engines[i].configure(retiring);} } } - std::array process(float left,float right) + std::array process(float left,float right,float returnBlend=0) { std::array result{};if(!prepared)return result;const auto safe=[](float x){return std::isfinite(x)?juce::jlimit(-16.0f,16.0f,x):0;}; - for(size_t i=0;i<4;++i){const float weight=weights[i].getNextValue(),wetWeight=retirement.next(i);if(wetWeight==0&&weight==0&&weights[i].getTargetValue()==0){if(running[i])engines[i].reset();running[i]=false;continue;}running[i]=true;const auto output=engines[i].process({safe(left),safe(right)},weights[i].getTargetValue()>0);result[0]+=wetWeight*output[0];result[1]+=wetWeight*output[1];result[2]+=weight;result[3]+=weight*output[2];result[4]+=weight*output[3];}return result; + for(size_t i=0;i<4;++i){const float weight=weights[i].getNextValue(),wetWeight=retirement.next(i);if(wetWeight==0&&weight==0&&weights[i].getTargetValue()==0){if(running[i])engines[i].reset();running[i]=false;continue;}running[i]=true;const auto output=engines[i].process({safe(left),safe(right)},weights[i].getTargetValue()>0,BuiltInReverbReturnLevels::gain(17+static_cast(i),returnBlend));const float returnWeight=BuiltInReverbReturnLevels::weighted(wetWeight,17+static_cast(i),returnBlend);result[0]+=returnWeight*output[0];result[1]+=returnWeight*output[1];result[2]+=weight;result[3]+=weight*output[2];result[4]+=weight*output[3];}return result; } double retiringTailSeconds()const noexcept{return retirement.retiringTailSeconds();} + const BuiltInInfiniteHoldBudget& risingInfiniteBudget()const noexcept{return engines[0].infiniteWriteBudget;} std::array processedFrames()const{return {engines[0].frames,engines[1].frames,engines[2].frames,engines[3].frames};} + juce::uint64 sparseProcessedFrames()const noexcept + { + juce::uint64 frames=0; + for(const auto& engine:engines)frames+=engine.readoutDiffusion.processedFrames(); + return frames; + } }; diff --git a/Source/BuiltInClearReverb.h b/Source/BuiltInClearReverb.h index 85ca34d..bbbf9ee 100644 --- a/Source/BuiltInClearReverb.h +++ b/Source/BuiltInClearReverb.h @@ -1,6 +1,9 @@ #pragma once +#include "BuiltInReverbDamping.h" #include +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbSpillover.h" +#include "BuiltInSettledReverbTap.h" #include #include @@ -13,7 +16,18 @@ class BuiltInClearReverb { float decay=2,size=.5f,damping=.5f,diffusion=.5f,preDelay=0,lowCut=20,highCut=20000,width=1,early=.5f; float inputDiffusion=.65f,onset=40,depth=.25f,speed=.3f,bassRatio=1,bassFrequency=500; - bool hold=false,holdInput=false; + bool hold=false,holdInput=false,refined=false,correctedControls=false; + bool operator==(const Settings& other) const noexcept + { + return decay==other.decay && size==other.size && damping==other.damping + && diffusion==other.diffusion && preDelay==other.preDelay + && lowCut==other.lowCut && highCut==other.highCut && width==other.width + && early==other.early && inputDiffusion==other.inputDiffusion + && onset==other.onset && depth==other.depth && speed==other.speed + && bassRatio==other.bassRatio && bassFrequency==other.bassFrequency + && hold==other.hold && holdInput==other.holdInput + && refined==other.refined && correctedControls==other.correctedControls; + } }; private: struct Delay @@ -43,19 +57,20 @@ class BuiltInClearReverb double phase=0,oscillatorSine=0,oscillatorCosine=1,rotationSine=0,rotationCosine=1; juce::uint32 seed=1; juce::SmoothedValue samples,loss,bassLoss; + BuiltInSettledReverbTap settledTap; std::array,4> tapLoss; float random() noexcept {seed^=seed<<13;seed^=seed>>17;seed^=seed<<5;return static_cast(seed&0xffffffu)/8388607.5f-1;} void reset(size_t index) noexcept - {delay.reset();low=damped=0;phase=0;seed=81731u+static_cast(index)*173891u;from=random();to=random();oscillatorSine=std::sin(static_cast(index)*1.713);oscillatorCosine=std::cos(static_cast(index)*1.713);} + {delay.reset();settledTap.reset();low=damped=0;phase=0;seed=81731u+static_cast(index)*173891u;from=random();to=random();oscillatorSine=std::sin(static_cast(index)*1.713);oscillatorCosine=std::cos(static_cast(index)*1.713);} }; struct Engine { std::array lines;std::array pre,earlyHistory; std::array,2> inputDiffusers; std::array lowState{},highState{}; - juce::SmoothedValue scatter,inputCoefficient,onset,depth,preSamples,damping,bassPole,lowPole,highPole,width,earlyLevel,hold,excitation,size; + juce::SmoothedValue scatter,inputCoefficient,onset,depth,preSamples,damping,dampingPole,bassPole,lowPole,highPole,width,earlyLevel,hold,excitation,size; Settings settings;double rate=48000;int mode=0,count=16,inputStages=4; - float normalise=.25f;bool initialized=false;juce::int64 drain=0;juce::uint64 frames=0; + float normalise=.25f;bool initialized=false,previousActive=false;juce::int64 drain=0;juce::uint64 frames=0,coefficientUpdates=0; void prepare(double sampleRate,int topology) { rate=sampleRate;mode=topology;count=mode==1?8:16;inputStages=mode==0?4:2;normalise=1/std::sqrt(static_cast(count)); @@ -63,11 +78,11 @@ class BuiltInClearReverb constexpr std::array roomTimes{.0071f,.0113f,.0179f,.0233f,.0097f,.0139f,.0199f,.0317f}; constexpr std::array randomTimes{.0293f,.0377f,.0431f,.0539f,.0611f,.0719f,.0797f,.0899f,.0311f,.0409f,.0479f,.0571f,.0677f,.0739f,.0833f,.0971f}; for(int i=0;i(i)];line.base=mode==0?plateTimes[static_cast(i)]:mode==1?roomTimes[static_cast(i)]:randomTimes[static_cast(i)];line.delay.prepare(static_cast(rate*(line.base*1.5f+.003f)));for(auto* value:{&line.samples,&line.loss,&line.bassLoss})value->reset(rate,.1);for(auto& value:line.tapLoss)value.reset(rate,.1);} + {auto& line=lines[static_cast(i)];line.base=mode==0?plateTimes[static_cast(i)]:mode==1?roomTimes[static_cast(i)]:randomTimes[static_cast(i)];line.delay.prepare(static_cast(rate*(line.base*1.5f+.003f)));line.settledTap.prepare(rate);for(auto* value:{&line.samples,&line.loss,&line.bassLoss})value->reset(rate,.1);for(auto& value:line.tapLoss)value.reset(rate,.1);} for(auto& line:pre)line.prepare(static_cast(rate*.501)); for(auto& line:earlyHistory)line.prepare(static_cast(rate*.111)); for(auto& channel:inputDiffusers)for(auto& line:channel)line.prepare(static_cast(rate*.121)); - for(auto* value:{&scatter,&inputCoefficient,&onset,&depth,&preSamples,&damping,&bassPole,&lowPole,&highPole,&width,&earlyLevel,&hold,&excitation,&size})value->reset(rate,.05); + for(auto* value:{&scatter,&inputCoefficient,&onset,&depth,&preSamples,&damping,&dampingPole,&bassPole,&lowPole,&highPole,&width,&earlyLevel,&hold,&excitation,&size})value->reset(rate,.05); reset();frames=0; } void reset() noexcept @@ -75,7 +90,13 @@ class BuiltInClearReverb double tail() const noexcept {return juce::jmin(120.0,settings.decay*juce::jmax(1.0f,settings.bassRatio)*2.0+2.0+settings.onset*.003+settings.preDelay*.001);} void configure(const Settings& next,bool active) noexcept { + // Targets continue smoothing in process(). Reapplying identical + // sanitized settings cannot change them, but used to recompute + // every line's decay powers and oscillator rotation each block. + // An active/retiring transition still releases Hold immediately. + if(initialized && previousActive==active && (!active || settings==next))return; if(active)settings=next; + previousActive=active;++coefficientUpdates; const auto set=[this](auto& value,float target){if(initialized)value.setTargetValue(target);else value.setCurrentAndTargetValue(target);}; const float sampleRate=static_cast(rate),scale=.5f+settings.size; for(int i=0;i::twoPi*juce::jmin(frequency,sampleRate*.45f)/sampleRate);}; set(scatter,settings.diffusion*.70710678118f);set(inputCoefficient,settings.inputDiffusion*.72f);set(onset,settings.onset*.001f*sampleRate); set(depth,settings.depth*sampleRate*.0012f);set(preSamples,settings.preDelay*.001f*sampleRate);set(size,scale); - set(damping,settings.damping);set(bassPole,pole(settings.bassFrequency));set(lowPole,pole(settings.lowCut));set(highPole,pole(settings.highCut)); + set(damping,settings.damping);set(dampingPole,settings.correctedControls?BuiltInReverbDamping::pole(.5f,rate):.5f);set(bassPole,pole(settings.bassFrequency));set(lowPole,pole(settings.lowCut));set(highPole,pole(settings.highCut)); set(width,settings.width);set(earlyLevel,settings.early);const bool held=active&&settings.hold; set(hold,held?1.0f:0.0f);set(excitation,held&&!settings.holdInput?0.0f:1.0f);initialized=true; } @@ -99,6 +120,7 @@ class BuiltInClearReverb ++frames;const float held=hold.getNextValue(),inject=excitation.getNextValue(),predelay=preSamples.getNextValue(); const float inputDiffusion=inputCoefficient.getNextValue(),spread=onset.getNextValue(),scale=size.getNextValue(); const float motion=depth.getNextValue()*(1-held),damp=damping.getNextValue()*(1-held),bass=bassPole.getNextValue(); + const float tonePole=dampingPole.getNextValue(); const float sine=scatter.getNextValue(),cosine=std::sqrt(juce::jmax(0.0f,1-sine*sine)); std::array input{active?left*inject:0,active?right*inject:0},early{}; constexpr std::array spreads{.127f,.193f,.283f,.397f}; @@ -131,10 +153,12 @@ class BuiltInClearReverb line.oscillatorCosine=line.oscillatorCosine*line.rotationCosine-line.oscillatorSine*line.rotationSine;line.oscillatorSine=nextSine; if((frames&1023u)==0){const double norm=std::hypot(line.oscillatorSine,line.oscillatorCosine);line.oscillatorSine/=norm;line.oscillatorCosine/=norm;} } + if(line.settledTap.needsProcessing(settings.refined)) + delayed=line.settledTap.process(delayed,length,settings.refined,settings.refined&&motion==0&&!line.samples.isSmoothing(),[&line](float tap) noexcept {return line.delay.read(tap);}); const float highLoss=line.loss.getNextValue(),lowLoss=line.bassLoss.getNextValue(); line.low=delayed+bass*(line.low-delayed); float value=delayed*(highLoss+(1-highLoss)*held)+(line.low*(lowLoss-highLoss))*(1-held); - line.damped=value+.5f*(line.damped-value);value+=damp*(line.damped-value);feedback[static_cast(i)]=value; + line.damped=value+tonePole*(line.damped-value);value+=damp*(line.damped-value);feedback[static_cast(i)]=value; float observed=delayed; if(mode==0) { @@ -169,8 +193,27 @@ class BuiltInClearReverb settings.inputDiffusion=safe(settings.inputDiffusion,0,1,.65f);settings.onset=safe(settings.onset,0,300,40);settings.depth=safe(settings.depth,0,1,.25f);settings.speed=safe(settings.speed,.05f,2,.3f);settings.bassRatio=safe(settings.bassRatio,.25f,4,1);settings.bassFrequency=safe(settings.bassFrequency,100,10000,500); for(size_t i=0;i(i);weights[i].setTargetValue(active?1.0f:0.0f);if(active||engines[i].drain>0)engines[i].configure(settings,active);spillWeights.configure(i,active,spillover,engines[i].drain>0);} } - std::array process(float left,float right) noexcept - {if(!prepared)return {};std::array result{};for(size_t i=0;i0);const float weight=weights[i].getNextValue(),wetWeight=spillWeights.next(i);result[0]+=wet[0]*wetWeight;result[1]+=wet[1]*wetWeight;result[2]+=weight;}return result;} + std::array process(float left,float right,float returnBlend=0) noexcept + { + if(!prepared)return {}; + std::array result{}; + for(size_t i=0;i0; + const float weight=weights[i].getNextValue(),wetWeight=spillWeights.next(i); + if(!active&&wetWeight<=0&&engines[i].drain>0)engines[i].reset(); + const auto wet=engines[i].process(left,right,active); + const float returnWeight=BuiltInReverbReturnLevels::weighted(wetWeight,24+static_cast(i),returnBlend); + result[0]+=wet[0]*returnWeight;result[1]+=wet[1]*returnWeight;result[2]+=weight; + } + return result; + } double retiringTailSeconds() const noexcept {double tail=0;for(size_t i=0;i(engines[i].drain)/engines[i].rate);return tail;} juce::uint64 processingFrames(size_t index) const noexcept{return engines[index].frames;} + juce::uint64 coefficientUpdateCount(size_t index) const noexcept{return engines[index].coefficientUpdates;} + bool isDormant() const noexcept + { + for(const auto& engine:engines)if(engine.drain>0)return false; + return true; + } }; diff --git a/Source/BuiltInConvolution.h b/Source/BuiltInConvolution.h index d56d71d..5dd7e56 100644 --- a/Source/BuiltInConvolution.h +++ b/Source/BuiltInConvolution.h @@ -5,6 +5,7 @@ #include "BuiltInIRBrightness.h" #include "BuiltInIRSourceBlend.h" #include "BuiltInIRGeometry.h" +#include "BuiltInReverbReturnLevels.h" // Source/shape edits run on the bridge's state-mutation worker. Complete FFT // engines are prepared before atomic publication; the callback only swaps, @@ -25,6 +26,13 @@ class BuiltInConvolution double sourceBlendLeft = 0, sourceBlendRight = 1; }; BuiltInConvolution() = default; + // Response/layout publication identity; never takes the metadata lock in + // the audio callback. The standalone dispatcher uses it only to abandon + // an already-proven, inaudible compatibility-engine sleep interval. + std::uint64_t publicationRevision() const noexcept + { + return publishedRevision.load(std::memory_order_acquire); + } ~BuiltInConvolution() { delete pendingEq.exchange(nullptr);delete pending.exchange(nullptr); delete active; delete fading; collect(); } bool loadFile(const juce::File& file, double seconds = 0, BuiltInIRPreparation* progress=nullptr) { @@ -54,14 +62,16 @@ class BuiltInConvolution Shape next; next.end = static_cast(tree.getProperty("irTrimSeconds",0.0)); if (!readShape(tree.getProperty("irShape"),next)) return false; - return restore(tree.getProperty("irData"),tree.getProperty("irName").toString(),next); + const auto name=tree.getProperty("irName").toString(); + const bool generated=static_cast(tree.getProperty("irGeneratedDefault",name=="Studio room (generated)")); + return restore(tree.getProperty("irData"),name,next,nullptr,generated); } bool edit(const juce::var& values, BuiltInIRPreparation* progress=nullptr) { - juce::MemoryBlock bytes; juce::String name; Shape next; - { const juce::ScopedLock lock(controlLock); bytes=sourceBytes; name=sourceName; next=shape; } + juce::MemoryBlock bytes; juce::String name; Shape next;bool generated=false; + { const juce::ScopedLock lock(controlLock); bytes=sourceBytes; name=sourceName; next=shape;generated=sourceGeneratedDefault; } if (!readShape(values,next)) return false; - return restore(juce::var(bytes),name,next,progress); + return restore(juce::var(bytes),name,next,progress,generated); } bool trim(double seconds) { @@ -71,6 +81,7 @@ class BuiltInConvolution { const juce::ScopedLock lock(controlLock); state.setProperty("irData",juce::var(sourceBytes),nullptr); state.setProperty("irName",sourceName,nullptr); + state.setProperty("irGeneratedDefault",sourceGeneratedDefault,nullptr); state.setProperty("irTrimSeconds",shape.end,nullptr); // Nested binary ValueTree preserves exact doubles; JSON decimal rounding can // otherwise move crop endpoints on full-state recall. @@ -103,7 +114,7 @@ class BuiltInConvolution samples.setSample(ch,i,i>=480?filtered[ch]*.025f*std::exp(-6.9f*static_cast(i)/57600.0f):0); } samples.setSample(0,480,.45f);samples.setSample(1,617,.4f); - return publish(std::move(samples),sourceRate,"Studio room (generated)",Shape{},progress); + return publish(std::move(samples),sourceRate,"Studio room (generated)",Shape{},progress,true); } void prepare(double sampleRate,int block) { @@ -112,11 +123,11 @@ class BuiltInConvolution const juce::ScopedLock preparationGuard(preparationLock); renderRate=sampleRate;maximumBlock=juce::jmax(1,block); delete pending.exchange(nullptr);delete active;active=nullptr;delete fading;fading=nullptr;collect(); - juce::MemoryBlock bytes;Shape settings; - {const juce::ScopedLock lock(controlLock);bytes=sourceBytes;settings=shape;} + juce::MemoryBlock bytes;Shape settings;bool generated=false; + {const juce::ScopedLock lock(controlLock);bytes=sourceBytes;settings=shape;generated=sourceGeneratedDefault;} targetCrossTerms.store(static_cast(settings.crossTerms)); juce::AudioBuffer samples;double sourceRate=0; - if(decode(juce::var(bytes),samples,sourceRate))active=build(samples,sourceRate,settings).release(); + if(decode(juce::var(bytes),samples,sourceRate)){active=build(samples,sourceRate,settings).release();active->generatedDefault=generated;} delete pendingEq.exchange(nullptr);equalizer.prepare(settings,sampleRate);previousEqualizer=equalizer; eqFade.reset(sampleRate,.05);eqFade.setCurrentAndTargetValue(1); {const juce::ScopedLock lock(controlLock);eqResponse=equalizer.response();eqSampleRate=sampleRate;tailSeconds.store(processedDuration+equalizer.tail);} @@ -134,7 +145,7 @@ class BuiltInConvolution validPre=0;position=0;low={};high={};initialized=false; drainRemaining=0;processedFrames=0; } - void process(juce::AudioBuffer& buffer,float delayMs,float lowCut,float highCut,float width,bool selected,bool applyOuterFilters=true) + void process(juce::AudioBuffer& buffer,float delayMs,float lowCut,float highCut,float width,bool selected,bool applyOuterFilters=true,const float* returnBlend=nullptr) { if(!fading)if(auto* next=pending.exchange(nullptr)) { @@ -192,7 +203,10 @@ class BuiltInConvolution for(int i=0;ioutput.getSample(ch,i);buffer.setSample(ch,start+i,fading?fading->output.getSample(ch,i)*(1-amount)+wet*amount:wet);} + const float calibration=returnBlend?returnBlend[start+i]:0; + const float activeGain=active->generatedDefault?BuiltInReverbReturnLevels::gain(7,calibration):1; + const float fadingGain=fading&&fading->generatedDefault?BuiltInReverbReturnLevels::gain(7,calibration):1; + for(int ch=0;choutput.getSample(ch,i);if(activeGain!=1)wet*=activeGain;float previous=fading?fading->output.getSample(ch,i):0;if(fadingGain!=1)previous*=fadingGain;buffer.setSample(ch,start+i,fading?previous*(1-amount)+wet*amount:wet);} } if(fading&&!fade.isSmoothing()){retire(fading);fading=nullptr;} } @@ -240,7 +254,7 @@ class BuiltInConvolution juce::dsp::Convolution cross{juce::dsp::Convolution::NonUniform{512}}; juce::AudioBuffer output,crossBuffer; juce::SmoothedValue crossGain; - bool matrix=false,splitOutputs=false;Kernel* next=nullptr; + bool matrix=false,splitOutputs=false,generatedDefault=false;Kernel* next=nullptr; // Offset the cross engine's internal partition clock by half a tail // partition. Priming with silence changes no causal output or latency, // but avoids running all four long-tail FFT accumulations in one small @@ -279,10 +293,11 @@ class BuiltInConvolution BuiltInIRColour::Eq equalizer,previousEqualizer;juce::SmoothedValue eqFade; Kernel* active=nullptr;Kernel* fading=nullptr; std::atomic pending{nullptr},retired{nullptr}; + std::atomic publishedRevision {0}; // Neither lock is acquired by audio process/reset/retire. Schema reads only // take the short metadata lock, never the expensive FFT preparation lock. juce::CriticalSection preparationLock,controlLock; - juce::MemoryBlock sourceBytes;juce::String sourceName;Shape shape; + juce::MemoryBlock sourceBytes;juce::String sourceName;Shape shape;bool sourceGeneratedDefault=false; BuiltInIRDecay decayEstimate; double geometrySourceRate=48000; double duration=0,processedDuration=0,renderRate=0;int maximumBlock=1,sourceChannels=2; @@ -358,11 +373,11 @@ class BuiltInConvolution ||bytes->getSize()!=20u+static_cast(channels)*static_cast(count)*4u)return false; samples.setSize(channels,count);for(int ch=0;chadvance(BuiltInIRPreparation::reading))return false; juce::AudioBuffer samples;double sourceRate=0;if(!decode(data,samples,sourceRate))return false; - return publish(std::move(samples),sourceRate,name,next,progress); + return publish(std::move(samples),sourceRate,name,next,progress,generated); } static juce::AudioBuffer shaped(const juce::AudioBuffer& source,double sourceRate,const Shape& values,BuiltInIRPreparation* progress=nullptr) { @@ -441,7 +456,7 @@ class BuiltInConvolution } kernel->diagonal.prepare({renderRate,static_cast(maximumBlock),2});return kernel; } - bool publish(juce::AudioBuffer&& samples,double sourceRate,const juce::String& name,Shape next,BuiltInIRPreparation* progress=nullptr) + bool publish(juce::AudioBuffer&& samples,double sourceRate,const juce::String& name,Shape next,BuiltInIRPreparation* progress=nullptr,bool generated=false) { if(progress&&!progress->advance(BuiltInIRPreparation::validating))return false; if(next.outputLayout==1&&samples.getNumChannels()!=4)return false; @@ -456,7 +471,7 @@ class BuiltInConvolution if(progress&&!progress->advance(BuiltInIRPreparation::shaping))return false; const juce::ScopedLock preparationGuard(preparationLock);collect(); bool unchangedIR=false,unchangedEq=false,unchangedAnalysis=false;BuiltInIRDecay nextDecay;double nextDuration=0;std::array envelope{}; - {const juce::ScopedLock lock(controlLock);unchangedIR=sourceBytes==bytes&&sameImpulse(shape,next);unchangedEq=sameEq(shape,next);unchangedAnalysis=shape.octaveAnalysis==next.octaveAnalysis;nextDuration=processedDuration;envelope=waveform;nextDecay=decayEstimate;} + {const juce::ScopedLock lock(controlLock);unchangedIR=sourceGeneratedDefault==generated&&sourceBytes==bytes&&sameImpulse(shape,next);unchangedEq=sameEq(shape,next);unchangedAnalysis=shape.octaveAnalysis==next.octaveAnalysis;nextDuration=processedDuration;envelope=waveform;nextDecay=decayEstimate;} juce::AudioBuffer processed; if(!unchangedIR||!unchangedAnalysis) { @@ -471,6 +486,7 @@ class BuiltInConvolution if(progress&&!progress->advance(BuiltInIRPreparation::preparing))return false; BuiltInIRColour::Eq eq;eq.prepare(next,renderRate>0?renderRate:48000);const auto nextResponse=eq.response(); std::unique_ptr kernel;if(renderRate>0&&!unchangedIR)kernel=buildShaped(std::move(processed),sourceRate,next); + if(kernel)kernel->generatedDefault=generated; std::unique_ptr update;if(renderRate>0&&!unchangedEq){update=std::make_unique();update->eq=eq;} // The previous unpublished engine is destroyed after metadata unlock. std::unique_ptr replaced;std::unique_ptr replacedEq; @@ -478,8 +494,9 @@ class BuiltInConvolution const juce::ScopedLock metadataGuard(controlLock); if(kernel)replaced.reset(pending.exchange(kernel.release())); if(update)replacedEq.reset(pendingEq.exchange(update.release())); - sourceBytes=std::move(bytes);sourceName=name;sourceChannels=samples.getNumChannels();geometrySourceRate=sourceRate;duration=fullDuration;shape=next; + sourceBytes=std::move(bytes);sourceName=name;sourceGeneratedDefault=generated;sourceChannels=samples.getNumChannels();geometrySourceRate=sourceRate;duration=fullDuration;shape=next; targetCrossTerms.store(static_cast(next.crossTerms),std::memory_order_relaxed); - decayEstimate=std::move(nextDecay);processedDuration=nextDuration;waveform=envelope;eqResponse=nextResponse;eqSampleRate=eq.rate;tailSeconds.store(processedDuration+eq.tail);return true; + decayEstimate=std::move(nextDecay);processedDuration=nextDuration;waveform=envelope;eqResponse=nextResponse;eqSampleRate=eq.rate;tailSeconds.store(processedDuration+eq.tail); + publishedRevision.fetch_add(1,std::memory_order_release);return true; } }; diff --git a/Source/BuiltInDriftingReverb.h b/Source/BuiltInDriftingReverb.h index 79f637c..4ca086c 100644 --- a/Source/BuiltInDriftingReverb.h +++ b/Source/BuiltInDriftingReverb.h @@ -1,6 +1,8 @@ #pragma once #include +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbSpillover.h" +#include "BuiltInSettledReverbTap.h" #include #include @@ -15,7 +17,7 @@ class BuiltInDriftingReverb final float preDelay = 0, lowCut = 20, highCut = 20000, width = 1; float drive = 6, wow = .2f, flutter = .1f, rate = .3f, emphasis = .5f; float bassRatio = 1, bassFrequency = 500; - bool hold = false, holdInput = false; + bool hold = false, holdInput = false, refined = false; }; private: struct Delay @@ -49,6 +51,7 @@ class BuiltInDriftingReverb final { Delay delay; juce::SmoothedValue length, loss, bassLoss; + BuiltInSettledReverbTap settledTap; float base = 0, dampingState = 0, bassState = 0, preState = 0, postState = 0; double phase = 0, flutterPhase = 0; float randomFrom = 0, randomTo = 0; @@ -56,7 +59,7 @@ class BuiltInDriftingReverb final float random() noexcept { seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; return static_cast(seed & 0xffffffu) / 8388607.5f - 1; } void reset(int index) noexcept { - delay.reset(); dampingState = bassState = preState = postState = 0; + delay.reset(); settledTap.reset(); dampingState = bassState = preState = postState = 0; phase = static_cast(index) / 17; flutterPhase = static_cast(index) / 23; seed = 15731u + static_cast(index) * 789221u; randomFrom = random(); randomTo = random(); } @@ -88,6 +91,7 @@ class BuiltInDriftingReverb final auto& line = lines[static_cast(i)]; line.base = times[static_cast(i)] * (mode == 1 ? .31f : mode == 2 ? .63f : 1.0f); line.delay.prepare(static_cast(rate * (line.base * 1.5f + .01f))); + line.settledTap.prepare(rate); for (auto* value : {&line.length, &line.loss, &line.bassLoss}) value->reset(rate, .1); } for (auto& line : predelay) line.prepare(static_cast(rate * .501)); @@ -154,7 +158,16 @@ class BuiltInDriftingReverb final line.flutterPhase += (3 + speed * 13) * (1 + static_cast(i) * .037f) / rate; if (line.phase >= 1) { line.phase -= 1; line.randomFrom = line.randomTo; line.randomTo = line.random(); } if (line.flutterPhase >= 1) line.flutterPhase -= 1; - const float raw = line.delay.read(line.length.getNextValue() + motion * (1 - held)); + const float length = line.length.getNextValue(); + const float original = line.delay.read(length + motion * (1 - held)); + float raw = original; + if (line.settledTap.needsProcessing(settings.refined)) + { + const bool stationary = settings.refined + && (held == 1 || (slowDepth == 0 && fastDepth == 0)) && !line.length.isSmoothing(); + raw = line.settledTap.process(original, length, settings.refined, stationary, + [&line](float tap) noexcept { return line.delay.read(tap); }); + } line.dampingState = raw + damp * (line.dampingState - raw); const float filtered = raw * held + line.dampingState * (1 - held); line.bassState = filtered + bass * (line.bassState - filtered); @@ -236,15 +249,18 @@ class BuiltInDriftingReverb final spillWeights.configure(i, active, spillover, engines[i].drain > 0); } } - std::array process(float left, float right) noexcept + std::array process(float left, float right, float returnBlend = 0) noexcept { if (!prepared) return {}; std::array output {}; for (size_t i = 0; i < engines.size(); ++i) { - const auto wet = engines[i].process(left, right, weights[i].getTargetValue() > 0); + const bool active = weights[i].getTargetValue() > 0; const float weight = weights[i].getNextValue(), wetWeight = spillWeights.next(i); - output[0] += wet[0] * wetWeight; output[1] += wet[1] * wetWeight; output[2] += weight; + if (!active && wetWeight <= 0 && engines[i].drain > 0) engines[i].reset(); + const auto wet = engines[i].process(left, right, active); + const float returnWeight = BuiltInReverbReturnLevels::weighted(wetWeight, 21 + static_cast(i), returnBlend); + output[0] += wet[0] * returnWeight; output[1] += wet[1] * returnWeight; output[2] += weight; } return output; } @@ -255,4 +271,9 @@ class BuiltInDriftingReverb final return seconds; } juce::uint64 processingFrames(size_t slot) const noexcept { return engines[slot].frames; } + bool isDormant() const noexcept + { + for (const auto& engine : engines) if (engine.drain > 0) return false; + return true; + } }; diff --git a/Source/BuiltInEQMIDIPrograms.h b/Source/BuiltInEQMIDIPrograms.h index a4cb662..d2a6c7b 100644 --- a/Source/BuiltInEQMIDIPrograms.h +++ b/Source/BuiltInEQMIDIPrograms.h @@ -11,7 +11,7 @@ struct BuiltInEQPreparedPrograms; struct BuiltInEQMIDIProgramBank { - static constexpr size_t valueCount=24*26+6, configurationCount=7; + static constexpr size_t valueCount=24*26+6, configurationCount=8; struct Entry { int bank=0,program=0; diff --git a/Source/BuiltInEQMIDIPrograms.inc b/Source/BuiltInEQMIDIPrograms.inc index dc8d06a..25a02d9 100644 --- a/Source/BuiltInEQMIDIPrograms.inc +++ b/Source/BuiltInEQMIDIPrograms.inc @@ -13,7 +13,7 @@ namespace std::array OpenStudioEQ::midiProgramConfiguration() const noexcept { const auto flag=[](float value){return value>=.5f?1.0f:0.0f;}; - return {flag(phaseMode.load()),static_cast(juce::jlimit(0,2,juce::roundToInt(phaseQuality.load()))),flag(minimumPhaseFIR.load()),flag(analogResponse.load()),flag(spectralProcessing.load()),flag(linearBandDynamics.load()),flag(externalDetector.load())}; + return {flag(phaseMode.load()),static_cast(juce::jlimit(0,2,juce::roundToInt(phaseQuality.load()))),flag(minimumPhaseFIR.load()),flag(analogResponse.load()),flag(spectralProcessing.load()),flag(linearBandDynamics.load()),flag(externalDetector.load()),flag(signalPath.load())}; } void OpenStudioEQ::publishMIDIPrograms(std::unique_ptr next) { @@ -107,7 +107,7 @@ void OpenStudioEQ::saveMIDIPrograms(juce::ValueTree& state) const for(int i=0;icount;++i) { const auto& entry=midiProgramOwner->entries[static_cast(i)];juce::ValueTree slot("Slot");slot.setProperty("bank",entry.bank,nullptr);slot.setProperty("program",entry.program,nullptr);slot.setProperty("name",entry.name,nullptr); - juce::Array values,configuration;for(float value:entry.values)values.add(value);for(float value:entry.configuration)configuration.add(value);slot.setProperty("values",values,nullptr);slot.setProperty("configuration",configuration,nullptr);map.addChild(slot,-1,nullptr); + juce::Array values,configuration;for(float value:entry.values)values.add(value);for(size_t f=0;f<7;++f)configuration.add(entry.configuration[f]);slot.setProperty("signalPath",entry.configuration[7],nullptr);slot.setProperty("values",values,nullptr);slot.setProperty("configuration",configuration,nullptr);map.addChild(slot,-1,nullptr); } state.addChild(map,-1,nullptr); } @@ -123,11 +123,12 @@ void OpenStudioEQ::restoreMIDIPrograms(const juce::ValueTree& state) { const auto slot=map.getChild(i);auto& entry=next->entries[static_cast(i)];valid=readEQMIDIInteger(slot.getProperty("bank"),0,16383,entry.bank)&&readEQMIDIInteger(slot.getProperty("program"),0,127,entry.program);entry.name=slot.getProperty("name").toString().substring(0,80); const auto valuesVar=slot.getProperty("values"),configurationVar=slot.getProperty("configuration");const auto* values=valuesVar.getArray();const auto* configuration=configurationVar.getArray(); - valid=valid&&slot.hasType("Slot")&&entry.bank>=0&&entry.bank<=16383&&entry.program>=0&&entry.program<=127&&values&&configuration&&values->size()==static_cast(entry.values.size())&&configuration->size()==static_cast(entry.configuration.size()); + valid=valid&&slot.hasType("Slot")&&entry.bank>=0&&entry.bank<=16383&&entry.program>=0&&entry.program<=127&&values&&configuration&&values->size()==static_cast(entry.values.size())&&configuration->size()==7; for(int old=0;valid&&oldentries[static_cast(old)].bank!=entry.bank||next->entries[static_cast(old)].program!=entry.program; if(!valid)break; size_t field=0;visitMIDIProgramParameters([&](auto&,float lo,float hi){const auto& property=(*values)[static_cast(field)];const float value=static_cast(property);valid=valid&&(property.isDouble()||property.isInt()||property.isInt64())&&std::isfinite(value)&&value>=lo&&value<=hi;entry.values[field++]=value;}); - for(size_t f=0;f(f)];const float value=static_cast(property);valid=valid&&(property.isDouble()||property.isInt()||property.isInt64())&&std::isfinite(value)&&value>=0&&value<=(f==1?2.0f:1.0f)&&std::floor(value)==value;entry.configuration[f]=value;} + int signalMode=0;valid=valid&&readEQMIDIInteger(slot.getProperty("signalPath",0),0,1,signalMode);entry.configuration[7]=static_cast(signalMode); + for(size_t f=0;f<7;++f){const auto& property=(*configuration)[static_cast(f)];const float value=static_cast(property);valid=valid&&(property.isDouble()||property.isInt()||property.isInt64())&&std::isfinite(value)&&value>=0&&value<=(f==1?2.0f:1.0f)&&std::floor(value)==value;entry.configuration[f]=value;} if(valid)++next->count; } } diff --git a/Source/BuiltInEQPhase.inc b/Source/BuiltInEQPhase.inc index 05ee225..2930411 100644 --- a/Source/BuiltInEQPhase.inc +++ b/Source/BuiltInEQPhase.inc @@ -179,7 +179,20 @@ double OpenStudioEQ::getTailLengthSeconds() const const int quality = juce::roundToInt(phaseQuality.load()); const int staticTail = usesPreparedEQ() ? BuiltInLinearPhaseEQ::tapCount(quality) + BuiltInLinearPhaseEQ::quantum : 0; const int spectralTail = frequencyProcessingRequested() ? BuiltInSpectralEQ::latency(quality) * 2 : 0; - return static_cast(staticTail + spectralTail) / juce::jmax(1.0, publishedSampleRate.load()); + const double fs = juce::jmax(1.0, publishedSampleRate.load()); + double iirTail = 0; + if (!usesPreparedEQ()) + for (int band = 0; band < bandCount; ++band) + { + std::array targets; + const int stages = buildBandTargets(band, bands[band].enabled.load() >= .5f, targets); + double bandTail = 0; + for (int stage = 0; stage < stages; ++stage) + bandTail += BuiltInFilterTail::biquad(targets[static_cast(stage)], fs); + iirTail += juce::jmax(bandTail, retainedBandTails[static_cast(band)].load()); + } + return BuiltInFilterTail::retain(retainedFilterTail, iirTail + + static_cast(staticTail + spectralTail) / fs + (iirTail > 0 ? .02 : 0)); } void OpenStudioEQ::processLinearPhase(juce::AudioBuffer& buffer, bool spectrumRequested, const juce::AudioBuffer& fullBuffer) @@ -208,8 +221,8 @@ void OpenStudioEQ::processLinearPhase(juce::AudioBuffer& buffer, bool spe wetL = mid + side; wetR = mid - side; } else { wetL *= level; wetR *= level; } - wetL = std::isfinite(wetL) ? juce::jlimit(-2.5f, 2.5f, wetL) : 0; - wetR = std::isfinite(wetR) ? juce::jlimit(-2.5f, 2.5f, wetR) : 0; + wetL = std::isfinite(wetL) ? (signalPath.load() >= .5f ? wetL : juce::jlimit(-2.5f, 2.5f, wetL)) : 0; + wetR = std::isfinite(wetR) ? (signalPath.load() >= .5f ? wetR : juce::jlimit(-2.5f, 2.5f, wetR)) : 0; const float mix = smoothedPowerMix.getNextValue(); buffer.setSample(0, i, wetL * mix + sample[2] * (1 - mix)); if (channels > 1) buffer.setSample(1, i, wetR * mix + sample[3] * (1 - mix)); diff --git a/Source/BuiltInEQPreparedPrograms.inc b/Source/BuiltInEQPreparedPrograms.inc index e667630..34c623b 100644 --- a/Source/BuiltInEQPreparedPrograms.inc +++ b/Source/BuiltInEQPreparedPrograms.inc @@ -1,7 +1,7 @@ void OpenStudioEQ::applyMIDIConfiguration(const std::array& values) noexcept { phaseMode.store(values[0]);phaseQuality.store(values[1]);minimumPhaseFIR.store(values[2]);analogResponse.store(values[3]); - spectralProcessing.store(values[4]);linearBandDynamics.store(values[5]);externalDetector.store(values[6]); + spectralProcessing.store(values[4]);linearBandDynamics.store(values[5]);externalDetector.store(values[6]);signalPath.store(values[7]); } std::shared_ptr OpenStudioEQ::preparedMIDIView() const diff --git a/Source/BuiltInEchoRoom.h b/Source/BuiltInEchoRoom.h index 545c5ba..467bc4a 100644 --- a/Source/BuiltInEchoRoom.h +++ b/Source/BuiltInEchoRoom.h @@ -1,5 +1,6 @@ #pragma once #include +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbSpillover.h" // Original multi-head echo and finite image-source room. No sampled room/tape model. @@ -127,10 +128,10 @@ class BuiltInEchoRoom else if(engines[i].initialized&&engines[i].settings.hold){auto outgoing=engines[i].settings;outgoing.hold=false;engines[i].configure(outgoing);} }} - std::array process(float left,float right) + std::array process(float left,float right,float returnBlend=0) { std::array result{};if(!prepared)return result;const auto safe=[](float x){return std::isfinite(x)?juce::jlimit(-16.0f,16.0f,x):0;}; - for(size_t i=0;i<2;++i){const float weight=weights[i].getNextValue(),wetWeight=retirement.next(i);if(wetWeight==0&&weight==0&&weights[i].getTargetValue()==0){if(running[i])engines[i].reset();running[i]=false;continue;}running[i]=true;const auto output=engines[i].process({safe(left),safe(right)},weights[i].getTargetValue()>0);result[0]+=wetWeight*output[0];result[1]+=wetWeight*output[1];result[2]+=weight;result[3]+=weight*output[2];result[4]+=weight*output[3];}return result; + for(size_t i=0;i<2;++i){const float weight=weights[i].getNextValue(),wetWeight=retirement.next(i);if(wetWeight==0&&weight==0&&weights[i].getTargetValue()==0){if(running[i])engines[i].reset();running[i]=false;continue;}running[i]=true;const auto output=engines[i].process({safe(left),safe(right)},weights[i].getTargetValue()>0);const float returnWeight=BuiltInReverbReturnLevels::weighted(wetWeight,15+static_cast(i),returnBlend);result[0]+=returnWeight*output[0];result[1]+=returnWeight*output[1];result[2]+=weight;result[3]+=weight*output[2];result[4]+=weight*output[3];}return result; } double retiringTailSeconds()const noexcept{return retirement.retiringTailSeconds();} std::array processedFrames()const{return {engines[0].frames,engines[1].frames};} diff --git a/Source/BuiltInEffects.cpp b/Source/BuiltInEffects.cpp index 8e2387d..d786100 100644 --- a/Source/BuiltInEffects.cpp +++ b/Source/BuiltInEffects.cpp @@ -293,6 +293,7 @@ double getFixedBiquadMagnitude(const OpenStudioIIRCoefficientSet& coefficients, OpenStudioEQ::OpenStudioEQ(bool standalone) : OpenStudioBuiltInEffect(standalone), bandCount(standalone ? maxBands : numBands) { + signalPath.store(standalone ? 1.0f : 0.0f); if(standalone) { matchCapture=std::make_unique(); linearPhase=std::make_unique(); spectralEQ=std::make_unique(); } for (int i = 0; i < bandCount; ++i) { @@ -477,6 +478,8 @@ void OpenStudioEQ::resetTargetWeights(bool prepare) void OpenStudioEQ::reset() { + retainedFilterTail.store(0); + for (auto& tail : retainedBandTails) tail.store(0); if(const auto* map=publishedMIDIPrograms.load(std::memory_order_acquire);map&&map->prepared)map->prepared->reset(); midiBankMSB.fill(0);midiBankLSB.fill(0);midiProgramObservation.store(0);midiProgramSampleOffset=0; draftPreview.reset(publishedSampleRate.load()); @@ -785,6 +788,10 @@ void OpenStudioEQ::updateBand(int b, int auditionIndex, bool forceImmediate) auto& targets = targetBandCoefficients[static_cast(b)]; targetStages[b] = buildBandTargets(b, shouldProcess, targets); + double bandTail = 0; + for (int stage = 0; stage < targetStages[b]; ++stage) + bandTail += BuiltInFilterTail::biquad(targets[static_cast(stage)], cachedSampleRate); + BuiltInFilterTail::retain(retainedBandTails[static_cast(b)], bandTail); activeStages[b] = juce::jmax( activeStages[b], targetStages[b]); @@ -1515,7 +1522,7 @@ void OpenStudioEQ::processEQAudioBlock(juce::AudioBuffer& fullBuffer) buffer.setSample(channel, sample, buffer.getSample(channel, sample) * (1 - listen) + detectorListenScratch.getSample(channel, sample)); } draftPreview.process(buffer); - sanitizeBuiltInBuffer(buffer, 2.5f); + sanitizeBuiltInBuffer(buffer, signalPath.load() >= .5f ? std::numeric_limits::max() : 2.5f); if (needsDryCopy) { @@ -1813,6 +1820,7 @@ void OpenStudioEQ::getEQStateInformationRaw(juce::MemoryBlock& destData, const s juce::ValueTree state("OpenStudioEQ"); if (linearPhase) { state.setProperty("phaseMode", configuration[0], nullptr); state.setProperty("phaseQuality", configuration[1], nullptr); state.setProperty("spectralProcessing", configuration[4], nullptr); state.setProperty("linearBandDynamics", configuration[5], nullptr); state.setProperty("minimumPhaseFIR", configuration[2], nullptr); state.setProperty("analogResponse", configuration[3], nullptr); } if (supportsExternalKey()) { state.setProperty("externalDetector", configuration[6], nullptr); state.setProperty("detectorListenBand", read(detectorListenBand), nullptr); } + state.setProperty("signalPath", configuration[7], nullptr); state.setProperty("outputGain", read(outputGain), nullptr); state.setProperty("autoGain", read(autoGain), nullptr); state.setProperty("editorBypass", read(editorBypass), nullptr); @@ -1859,6 +1867,7 @@ void OpenStudioEQ::setStateInformation(const void* data, int sizeInBytes) draftPreview.reset(publishedSampleRate.load()); auto state = juce::ValueTree::readFromData(data, static_cast(sizeInBytes)); if (!state.isValid() || state.getType().toString() != "OpenStudioEQ") return; + signalPath.store(supportsExternalKey() && static_cast(state.getProperty("signalPath", 0.0f)) >= .5f ? 1.0f : 0.0f); preparedMIDILatency.store(-1); externalDetector.store(supportsExternalKey() && static_cast(state.getProperty("externalDetector", 0.0f)) >= .5f ? 1.0f : 0.0f); const auto finiteProperty = [&](const juce::Identifier& id, float fallback, float low, float high) @@ -2739,7 +2748,13 @@ void OpenStudioCompressor::processStandaloneBlock(juce::AudioBuffer& buff const float dry = read(ch, static_cast(getLatencySamples())); const float shaped = useOriginalColour ? colouredAudio[static_cast(ch)] : dry * gain; const float processed = vcaWeight == 0 ? shaped : shaped * priorWeight + vcaAudio[static_cast(ch)] / vcaScale; - mixed[static_cast(ch)] = dry * (1.0f - wet) + boundProcessedWetSample(noise[static_cast(ch)]==0?processed:processed+noise[static_cast(ch)]) * wet; + // Clean is a linear gain law. Retain the historical bound for + // coloured models and their outgoing crossfades/noise instead. + const bool cleanLinear = selected == 1 && !useOriginalColour && fetBlend == 0 + && opticalWeight == 0 && vcaWeight == 0 && noise[static_cast(ch)] == 0; + mixed[static_cast(ch)] = dry * (1.0f - wet) + boundProcessedWetSample( + noise[static_cast(ch)] == 0 ? processed : processed + noise[static_cast(ch)], + cleanLinear ? std::numeric_limits::max() : 2.5f) * wet; } const auto monitored=punchExtras.listen(mixed[0],channels==2?mixed[1]:mixed[0]); for(int ch=0;ch(ch)];buffer.setSample(ch,i,output);outPeak=juce::jmax(outPeak,std::abs(output));} @@ -3079,6 +3094,9 @@ void OpenStudioGate::processBlock(juce::AudioBuffer& fullBuffer, juce::Mi const int numChannels = buffer.getNumChannels(); if (numSamples <= 0 || numChannels <= 0) return; + // Standalone gating attenuates; it must not act as an unrelated clipper. + // The embedded NAM gate preserves its historical bounded wet path. + const float wetLimit = supportsExternalKey() ? std::numeric_limits::max() : 2.5f; smoothedMix.setTargetValue( juce::jlimit( 0.0f, 1.0f, @@ -3185,7 +3203,7 @@ void OpenStudioGate::processBlock(juce::AudioBuffer& fullBuffer, juce::Mi const float dry = buffer.getSample(ch, i); const float wet = boundProcessedWetSample( - dry * currentGain); + dry * currentGain, wetLimit); buffer.setSample( ch, i, diff --git a/Source/BuiltInEffects.h b/Source/BuiltInEffects.h index 94d1d81..781d0dd 100644 --- a/Source/BuiltInEffects.h +++ b/Source/BuiltInEffects.h @@ -1,4 +1,5 @@ #pragma once +#include "BuiltInFilterTail.h" #include "BuiltInEQMIDIPrograms.h" #include "BuiltInAlignmentCapture.h" @@ -109,7 +110,10 @@ class OpenStudioEQ : public OpenStudioBuiltInEffect static constexpr int numBands = 8; // legacy channel-strip/native-editor count static constexpr int maxBands = 24; + std::atomic signalPath {0}; // corrected for new standalone instances only const int bandCount; + mutable std::atomic retainedFilterTail {0}; + std::array, maxBands> retainedBandTails {}; static constexpr std::array defaultFrequencies { 20.0f, 100.0f, 250.0f, 500.0f, 1000.0f, 2500.0f, 6000.0f, 20000.0f }; struct BandParams diff --git a/Source/BuiltInEffects2.cpp b/Source/BuiltInEffects2.cpp index 892257b..29cc0e8 100644 --- a/Source/BuiltInEffects2.cpp +++ b/Source/BuiltInEffects2.cpp @@ -303,7 +303,8 @@ namespace return juce::jlimit(1.0f, static_cast(sampleRate * 0.475), frequency); } - void prepareRealtimeFilterLut(std::vector& lut, + template + void prepareRealtimeFilterLut(std::vector>& lut, double sampleRate, float nominalMinimum, float nominalMaximum, @@ -319,10 +320,10 @@ namespace { const double proportion = static_cast(index) / static_cast(lut.size() - 1); - const float frequency = static_cast(std::exp(logMinimum + proportion * logRange)); + const Sample frequency = static_cast(std::exp(logMinimum + proportion * logRange)); const auto coefficients = highPass - ? juce::dsp::IIR::Coefficients::makeHighPass(sampleRate, frequency) - : juce::dsp::IIR::Coefficients::makeLowPass(sampleRate, frequency); + ? juce::dsp::IIR::Coefficients::makeHighPass(sampleRate, frequency) + : juce::dsp::IIR::Coefficients::makeLowPass(sampleRate, frequency); const auto& source = coefficients->coefficients; jassert(source.size() == static_cast(lut[index].size())); for (size_t coefficient = 0; coefficient < lut[index].size(); ++coefficient) @@ -330,8 +331,9 @@ namespace } } - const OpenStudioIIRCoefficientSet& lookupRealtimeFilterLut( - const std::vector& lut, + template + const std::array& lookupRealtimeFilterLut( + const std::vector>& lut, double sampleRate, float frequency, float nominalMinimum, @@ -353,8 +355,9 @@ namespace return lut[index]; } - void writeRealtimeFilterCoefficients(juce::dsp::IIR::Filter& filter, - const OpenStudioIIRCoefficientSet& coefficients) noexcept + template + void writeRealtimeFilterCoefficients(juce::dsp::IIR::Filter& filter, + const std::array& coefficients) noexcept { jassert(filter.coefficients != nullptr); if (filter.coefficients == nullptr) @@ -369,19 +372,21 @@ namespace destination.set(static_cast(coefficient), coefficients[coefficient]); } - void writeRealtimeFilterCoefficients(juce::dsp::IIR::Filter& left, - juce::dsp::IIR::Filter& right, - const OpenStudioIIRCoefficientSet& coefficients) noexcept + template + void writeRealtimeFilterCoefficients(juce::dsp::IIR::Filter& left, + juce::dsp::IIR::Filter& right, + const std::array& coefficients) noexcept { writeRealtimeFilterCoefficients(left, coefficients); if (right.coefficients != left.coefficients) writeRealtimeFilterCoefficients(right, coefficients); } + template bool smoothRealtimeFilterCoefficients( - juce::dsp::IIR::Filter& left, - juce::dsp::IIR::Filter& right, - const OpenStudioIIRCoefficientSet& target, + juce::dsp::IIR::Filter& left, + juce::dsp::IIR::Filter& right, + const std::array& target, float smoothingCoefficient) noexcept { if (left.coefficients == nullptr @@ -404,15 +409,22 @@ namespace rightCoefficients.getRawDataPointer(); const bool sharedCoefficients = leftRaw == rightRaw; + // Keep existing float smoothing unchanged. Double high-pass filters + // need much finer convergence: their DC denominator may be ~1e-7 at + // an embedded 384kHz rate, smaller than the old float stop threshold. + constexpr Sample snapTolerance = sizeof(Sample) == sizeof(float) + ? static_cast(1.0e-7f) : static_cast(1.0e-14); + constexpr Sample stopTolerance = sizeof(Sample) == sizeof(float) + ? static_cast(1.0e-6f) : static_cast(1.0e-12); bool smoothing = false; for (size_t index = 0; index < target.size(); ++index) { - const float difference = + const Sample difference = target[index] - leftRaw[index]; - const float next = - std::abs(difference) <= 1.0e-7f + const Sample next = + std::abs(difference) <= snapTolerance ? target[index] : leftRaw[index] + difference @@ -423,7 +435,7 @@ namespace smoothing = smoothing || std::abs(target[index] - next) - > 1.0e-6f; + > stopTolerance; } return smoothing; } @@ -965,6 +977,12 @@ namespace { "octaverDownMix", 0.32 }, { "octaverUpMix", 0.18 }, { "octaverDirectMix", 1.0 }, + { "fuzzEnabled", 0.0 }, + { "fuzzDrive", 0.5 }, + { "fuzzTone", 0.5 }, + { "fuzzLevelDb", 0.0 }, + { "fuzzVoice", 0.0 }, + { "fuzzCleanMix", 0.0 }, { "preEqEnabled", 0.0 }, { "preEq120Db", 0.0 }, { "preEq250Db", 0.0 }, @@ -2509,6 +2527,16 @@ void OpenStudioDelay::prepareToPlay(double sampleRate, int samplesPerBlock) alternateFeedbackHPF_L, alternateFeedbackHPF_R, pendingFeedbackHPFCoefficients); + // Allocate the final IIR order here, including inactive morph banks. + // JUCE otherwise lazily resizes state on the first processSample(). + feedbackLPF_L.reset(); + feedbackLPF_R.reset(); + feedbackHPF_L.reset(); + feedbackHPF_R.reset(); + alternateFeedbackLPF_L.reset(); + alternateFeedbackLPF_R.reset(); + alternateFeedbackHPF_L.reset(); + alternateFeedbackHPF_R.reset(); feedbackFiltersUseAlternate = false; feedbackFilterMorphActive = false; feedbackFilterChangePending = false; @@ -4635,6 +4663,8 @@ OpenStudioReverb::OpenStudioReverb(bool standalone) .withOutput("IR cross paths", juce::AudioChannelSet::stereo(), standalone)), standaloneBanking(standalone) { + reverbReturnMode.store(standaloneBanking ? 1.0f : 0.0f); + reverbControlMode.store(standaloneBanking ? 1.0f : 0.0f); if(standaloneBanking)retroSpace=std::make_unique(); for(size_t i=0;i(sampleRate)); retiringReverbTailSeconds.store(0); peakHold.reset(); @@ -4870,6 +4902,17 @@ void OpenStudioReverb::prepareToPlay(double sampleRate, int samplesPerBlock) if (standaloneBanking) { additionalSpaces.prepare(sampleRate, static_cast(algorithm.load())); + standaloneWetRamp.reset(sampleRate, .02); standaloneDryRamp.reset(sampleRate, .02); + standaloneWetRamp.setCurrentAndTargetValue(juce::jlimit(0.0f,1.0f,wetLevel.load())); + standaloneDryRamp.setCurrentAndTargetValue(juce::jlimit(0.0f,1.0f,dryLevel.load())); + smoothedReturnCalibration.reset(sampleRate, 0.02); + const float returnMode = reverbReturnMode.load(std::memory_order_relaxed); + smoothedReturnCalibration.setCurrentAndTargetValue(std::isfinite(returnMode) && returnMode >= .5f ? 1.0 : 0.0); + returnCalibrationBlock.resize(static_cast(juce::jmax(1, samplesPerBlock))); + smoothedStandaloneOutputGain.reset(sampleRate, 0.02); + const float trimDb = outputTrimDb.load(std::memory_order_relaxed); + smoothedStandaloneOutputGain.setCurrentAndTargetValue(juce::Decibels::decibelsToGain( + std::isfinite(trimDb) ? juce::jlimit(-60.0f, 12.0f, trimDb) : 0.0f)); plateColour.prepare(sampleRate); springSpace.prepare(sampleRate); echoRoom.prepare(sampleRate,isEchoRoom()?static_cast(echoRoomSlot()):-1); @@ -5084,6 +5127,16 @@ void OpenStudioReverb::prepareToPlay(double sampleRate, int samplesPerBlock) 1000.0f, 20000.0f)); + // JUCE allocates IIR state when the coefficient order changes. The initial + // reset above used the constructor's first-order identity coefficients; + // size the final second-order histories here, before any audio callback or + // compatibility wake can call processSample()/reset(). All histories stay + // zero, preserving the prepared DSP response exactly. + for (auto* filter : {&wetLowCutL, &wetLowCutR, &wetHighCutL, &wetHighCutR, + &v3WetLowCutAlternateL, &v3WetLowCutAlternateR, + &v3WetHighCutAlternateL, &v3WetHighCutAlternateR}) + filter->reset(); + const int scratchChannels = juce::jmax(2, getTotalNumOutputChannels()); const int scratchSamples = juce::jmax(samplesPerBlock, 16384); dryBuffer.setSize(scratchChannels, scratchSamples, false, false, true); @@ -5184,6 +5237,7 @@ void OpenStudioReverb::prepareToPlay(double sampleRate, int samplesPerBlock) v2DiffusionPool.assign( static_cast(v2DiffusionSamples), 0.0f); + v2ValidDiffusionHistorySamples.fill(0); v2LowBandState.fill(0.0f); v2BelowHighBandState.fill(0.0f); v2LowBandGain.fill(0.85f); @@ -5794,6 +5848,12 @@ void OpenStudioReverb::processBlock(juce::AudioBuffer& buffer, juce::Midi if (const auto bpm = position->getBpm(); bpm && std::isfinite(*bpm)) workflowTempo.store(juce::jlimit(10.0f,300.0f,static_cast(*bpm))); workflow.configure(!isSpatialSpace() && predelaySync.load() >= .5f, effectivePredelay(), wetDuckDepth.load(), wetDuckThreshold.load(), wetDuckRelease.load()); convolutionCrossWorkflow.configure(!isSpatialSpace() && predelaySync.load() >= .5f, effectivePredelay(), wetDuckDepth.load(), wetDuckThreshold.load(), wetDuckRelease.load()); + const bool correctedControls = reverbControlMode.load() >= .5f; + standaloneWetRamp.setTargetValue(juce::jlimit(0.0f,1.0f,wetLevel.load())); + standaloneDryRamp.setTargetValue(juce::jlimit(0.0f,1.0f,dryLevel.load())); + const auto configuredRouting = compatibilityRouting(); + const float returnMode = reverbReturnMode.load(std::memory_order_relaxed); + smoothedReturnCalibration.setTargetValue(std::isfinite(returnMode) && returnMode >= .5f ? 1.0 : 0.0); const bool convolutionSelected=algorithm.load()==7, spillover=tailSpillover.load()>=.5f; convolutionWeight.setTargetValue(convolutionSelected ? 1.0f : 0.0f); if(convolutionSelected||!spillover)convolutionOuterSettings={enginePredelay(),lowCut.load(),highCut.load(),width.load(),irModDepth.load(),irModRate.load(),irExtensionControls[0].load(),irExtensionControls[1].load(),irExtensionControls[2].load(),irExtensionControls[3].load(),irExtensionControls[4].load(),irExtensionControls[5].load()}; @@ -5805,43 +5865,45 @@ void OpenStudioReverb::processBlock(juce::AudioBuffer& buffer, juce::Midi modalPlate.configure(algorithm.load()==2&&plateEngine.load()>=1.5f,{decayTime.load(),damping.load(),enginePredelay(),lowCut.load(),highCut.load(),width.load(),freezeMode.load()>=.5f,holdAcceptsInput()},spillover); plateSpace.configure(algorithm.load() == 2 && plateEngine.load() >= .5f && plateEngine.load() < 1.5f ? juce::jlimit(0, 2, juce::roundToInt(plateCharacter.load())) : -1, { decayTime.load(), damping.load(), diffusion.load(), enginePredelay(), lowCut.load(), highCut.load(), - width.load(), plateModulation.load(), freezeMode.load() >= .5f, holdAcceptsInput(), juce::roundToInt(plateToneControls[0].load()), plateToneControls[1].load(), plateToneControls[2].load()>=.5f }, tailSpillover.load() >= .5f ); + width.load(), plateModulation.load(), freezeMode.load() >= .5f, holdAcceptsInput(), juce::roundToInt(plateToneControls[0].load()), plateToneControls[1].load(), plateToneControls[2].load()>=.5f, reverbCharacterMode.load()>=.5f, correctedControls }, tailSpillover.load() >= .5f ); plateColour.configure({pc[0].load(),pc[1].load(),pc[2].load(),pc[3].load(),pc[4].load(),pc[5].load(),pc[6].load(),pc[7].load(),pc[8].load(),pc[9].load()},(algorithm.load()==2&&plateEngine.load()>=.5f)||(tailSpillover.load()>=.5f&&(plateSpace.retiringTailSeconds()>0||modalPlate.retiringTailSeconds()>0))); studioSpace.configure(isStudioSpace() && studioEngines[studioSlot()].load() >= .5f ? studioTopology() : -1, { decayTime.load(), roomSize.load(), damping.load(), diffusion.load(), enginePredelay(), lowCut.load(), highCut.load(), - earlyLevel.load(), width.load(), studioModulation[studioSlot()].load(), studioBassRatio[studioSlot()].load(), freezeMode.load() >= .5f, holdAcceptsInput(), juce::roundToInt(studioToneControls[studioSlot()][0].load()), studioToneControls[studioSlot()][1].load(), studioToneControls[studioSlot()][2].load()>=.5f }, tailSpillover.load() >= .5f ); + earlyLevel.load(), width.load(), studioModulation[studioSlot()].load(), studioBassRatio[studioSlot()].load(), freezeMode.load() >= .5f, holdAcceptsInput(), juce::roundToInt(studioToneControls[studioSlot()][0].load()), studioToneControls[studioSlot()][1].load(), studioToneControls[studioSlot()][2].load()>=.5f, correctedControls }, tailSpillover.load() >= .5f ); vintageSpace.configure(isVintageSpace() ? static_cast(vintageSlot()) : -1, { decayTime.load(), roomSize.load(), damping.load(), diffusion.load(), enginePredelay(), lowCut.load(), highCut.load(), width.load(), vintageColour[vintageSlot()].load(), vintageModulation[vintageSlot()].load(), vintageRate[vintageSlot()].load(), vintageBassRatio[vintageSlot()].load(), vintageBassFrequency[vintageSlot()].load(), vintageConversion[vintageSlot()].load(), vintageBuildUp[vintageSlot()].load(), vintageInputDiffusion[vintageSlot()].load(), vintageTankRate[vintageSlot()].load() }, tailSpillover.load() >= .5f ); const auto& retro=retroControls[retroSlot()]; retroSpace->configure(isRetroSpace()?static_cast(retroSlot()):-1, {roomSize.load(),damping.load(),diffusion.load(),enginePredelay(),lowCut.load(),highCut.load(),width.load(),earlyLevel.load(), - retro[0].load(),retro[1].load(),retro[2].load(),retro[3].load(),retro[4].load(),retro[5].load(),retro[6].load(),retro[7].load(),retro[8].load(),freezeMode.load()>=.5f,holdAcceptsInput()},spillover); + retro[0].load(),retro[1].load(),retro[2].load(),retro[3].load(),retro[4].load(),retro[5].load(),retro[6].load(),retro[7].load(),retro[8].load(),freezeMode.load()>=.5f,holdAcceptsInput(),reverbCharacterMode.load()>=.5f},spillover); const auto& clear=clearControls[clearSlot()]; clearSpace.configure(isClearSpace()?static_cast(clearSlot()):-1, {decayTime.load(),roomSize.load(),damping.load(),diffusion.load(),enginePredelay(),lowCut.load(),highCut.load(),width.load(),earlyLevel.load(), - clear[0].load(),clear[1].load(),clear[2].load(),clear[3].load(),clear[4].load(),clear[5].load(),freezeMode.load()>=.5f,holdAcceptsInput()},spillover); + clear[0].load(),clear[1].load(),clear[2].load(),clear[3].load(),clear[4].load(),clear[5].load(),freezeMode.load()>=.5f,holdAcceptsInput(),reverbCharacterMode.load()>=.5f,correctedControls},spillover); const auto& drift = driftingControls[driftingSlot()]; driftingSpace.configure(isDriftingSpace() ? static_cast(driftingSlot()) : -1, {decayTime.load(),roomSize.load(),damping.load(),diffusion.load(),enginePredelay(),lowCut.load(),highCut.load(),width.load(), - drift[0].load(),drift[1].load(),drift[2].load(),drift[3].load(),drift[4].load(),drift[5].load(),drift[6].load(),freezeMode.load()>=.5f,holdAcceptsInput()},spillover); + drift[0].load(),drift[1].load(),drift[2].load(),drift[3].load(),drift[4].load(),drift[5].load(),drift[6].load(),freezeMode.load()>=.5f,holdAcceptsInput(),reverbCharacterMode.load()>=.5f},spillover); const auto& amb=ambientControls[ambientSlot()]; - ambientSpace.configure(isAmbientSpace()?static_cast(ambientSlot()):-1,{ambientSlot()==2?amb[8].load():decayTime.load(),roomSize.load(),damping.load(),diffusion.load(),enginePredelay(),lowCut.load(),highCut.load(),width.load(),amb[0].load(),amb[1].load(),amb[2].load(),amb[3].load(),amb[4].load(),amb[5].load(),amb[6].load(),amb[7].load(),amb[9].load(),amb[10].load()}, tailSpillover.load()>=.5f); + ambientSpace.configure(isAmbientSpace()?static_cast(ambientSlot()):-1,{ambientSlot()==2?amb[8].load():decayTime.load(),roomSize.load(),damping.load(),diffusion.load(),enginePredelay(),lowCut.load(),highCut.load(),width.load(),amb[0].load(),amb[1].load(),amb[2].load(),amb[3].load(),amb[4].load(),amb[5].load(),amb[6].load(),amb[7].load(),amb[9].load(),amb[10].load(),reverbCharacterMode.load()>=.5f,ambBody.load()}, tailSpillover.load()>=.5f); const auto& er=echoRoomControls[echoRoomSlot()]; echoRoom.configure(isEchoRoom()?static_cast(echoRoomSlot()):-1,{roomSize.load(),damping.load(),diffusion.load(),enginePredelay(),lowCut.load(),highCut.load(),width.load(),er[0].load(),er[1].load(),er[2].load(),er[3].load(),er[4].load(),er[5].load(),er[6].load(),er[7].load(),(echoRoomSlot()==0?magneticHold.load():positionedHold.load())>=.5f,holdAcceptsInput()}, tailSpillover.load()>=.5f); const auto& spatial=spatialControls[spatialSlot()]; spatialSpace.configure(isSpatialSpace()?static_cast(spatialSlot()):-1, - {decayTime.load(),roomSize.load(),damping.load(),diffusion.load(),effectivePredelay(),spatial[1].load(),lowCut.load(),highCut.load(),width.load(),spatial[4].load(),spatial[5].load(),spatial[6].load(),spatial[7].load(),freezeMode.load()>=.5f,holdAcceptsInput(),spatialLowShelf[spatialSlot()].load()}, tailSpillover.load()>=.5f); + {decayTime.load(),roomSize.load(),damping.load(),diffusion.load(),effectivePredelay(),spatial[1].load(),lowCut.load(),highCut.load(),width.load(),spatial[4].load(),spatial[5].load(),spatial[6].load(),spatial[7].load(),freezeMode.load()>=.5f,holdAcceptsInput(),spatialLowShelf[spatialSlot()].load(),correctedControls}, tailSpillover.load()>=.5f); const auto& sp=springControls; const bool dispersiveSpring=algorithm.load()==4&&sp[0].load()>=.5f; springSpace.configure(dispersiveSpring,{decayTime.load(),damping.load(),enginePredelay(),lowCut.load(),highCut.load(),width.load(),sp[1].load()+1,sp[2].load(),sp[3].load(),sp[4].load(),sp[5].load(),sp[6].load(),springHold.load()>=.5f,holdAcceptsInput()}, tailSpillover.load()>=.5f); additionalSpaces.configure(dispersiveSpring?-1:static_cast(algorithm.load()), { decayTime.load(), roomSize.load(), damping.load(), diffusion.load(), enginePredelay(), lowCut.load(), highCut.load(), shimmerAmount.load(), width.load(), shimmerPitchA.load(), shimmerPitchB.load(), shimmerVoiceMix.load(), nonlinearShape.load(), shimmerVoiceEngine.load(), - creativeControls[0].load(), creativeControls[1].load(), creativeControls[2].load(), creativeControls[3].load(), creativeControls[4].load(), shimmerHold.load() >= .5f, holdAcceptsInput(), nonlinearModulation.load(), nonlinearRate.load(), nonlinearHold.load()>=.5f },tailSpillover.load()>=.5f); + creativeControls[0].load(), creativeControls[1].load(), creativeControls[2].load(), creativeControls[3].load(), creativeControls[4].load(), shimmerHold.load() >= .5f, holdAcceptsInput(), nonlinearModulation.load(), nonlinearRate.load(), nonlinearHold.load()>=.5f, shimmerScheduling.load()>=.5f, correctedControls },tailSpillover.load()>=.5f); for (int start = 0; start < buffer.getNumSamples(); start += additionalDry.getNumSamples()) { const int count = juce::jmin(additionalDry.getNumSamples(), buffer.getNumSamples() - start); + for (int sample = 0; sample < count; ++sample) + returnCalibrationBlock[static_cast(sample)] = static_cast(smoothedReturnCalibration.getNextValue()); float* pointers[2] {}; for (int ch = 0; ch < channels; ++ch) { @@ -5853,13 +5915,46 @@ void OpenStudioReverb::processBlock(juce::AudioBuffer& buffer, juce::Midi dryRamp.setTargetValue(juce::jlimit(0.0f,1.0f,dryLevel.load())); const float legacyDryStart = dryRamp.getCurrentValue(); auto endRamp = dryRamp; const float legacyDryEnd = endRamp.skip(count); - processSelectedEngine(segment, midi); + const auto routing = compatibilityRouting(); + if (!compatibilityRoutingKnown || !(routing == provenCompatibilityRouting) + || !(routing == configuredRouting)) + { + provenCompatibilityRouting = routing; + compatibilityRoutingKnown = true; + compatibilityCoveredSamples = 0; + } + // Every dedicated selection ramp is at most 50 ms. We additionally + // require a full 50 ms of observed, exactly-zero compatibility weight + // under the same routing identity before omitting a future segment. + // Thus transitions and compatibility selections always run the DSP. + const int coverageProofSamples = juce::jmax(1, + static_cast(std::ceil(cachedSampleRate * 0.05))); + const bool skipCompatibility = compatibilitySleepEnabled + && routing == configuredRouting && canMaskCompatibility(routing) + && compatibilityCoveredSamples >= coverageProofSamples; + if (skipCompatibility) + { + compatibilitySleeping = true; + advanceCompatibilitySmoothers(count); + } + else + { + if (compatibilitySleeping) + { + // Only the inaudible compatibility history is discarded. + // Dedicated tanks, convolution and workflow remain live. + invalidateCompatibilityHistory(); + compatibilitySleeping = false; + } + processSelectedEngine(segment, midi); + } + bool compatibilityFullyCovered = true; for (int ch = 0; ch < 2; ++ch) convolutionWet.copyFrom(ch, 0, additionalDry, juce::jmin(ch, channels - 1), 0, count); float* convPointers[] = { convolutionWet.getWritePointer(0), convolutionWet.getWritePointer(1), convolutionWet.getWritePointer(2), convolutionWet.getWritePointer(3) }; juce::AudioBuffer convSegment(convPointers, 4, count); juce::AudioBuffer crossSegment(convPointers + 2, 2, count); const bool convolutionWasAwake=!convolutionSpace.isDormant(); - convolutionSpace.process(convSegment,convolutionOuterSettings[0],convolutionOuterSettings[1],convolutionOuterSettings[2],convolutionOuterSettings[3],convolutionSelected); + convolutionSpace.process(convSegment,convolutionOuterSettings[0],convolutionOuterSettings[1],convolutionOuterSettings[2],convolutionOuterSettings[3],convolutionSelected,true,returnCalibrationBlock.data()); const bool extensionWasAwake=convolutionExtension.isDraining(); if(convolutionSelected||convolutionWasAwake||extensionWasAwake)convolutionExtension.process(convSegment); if(convolutionSelected||convolutionWasAwake||extensionWasAwake||convolutionMotion.isDraining()){convolutionMotion.configure(convolutionOuterSettings[4],convolutionOuterSettings[5]);convolutionMotion.process(convSegment);} @@ -5867,32 +5962,37 @@ void OpenStudioReverb::processBlock(juce::AudioBuffer& buffer, juce::Midi if(convolutionSelected||convolutionWasAwake||crossExtensionWasAwake)convolutionCrossExtension.process(crossSegment); if(convolutionSelected||convolutionWasAwake||crossExtensionWasAwake||convolutionCrossMotion.isDraining()) {convolutionCrossMotion.configure(convolutionOuterSettings[4],convolutionOuterSettings[5]);convolutionCrossMotion.process(crossSegment);} - const float wet = juce::jlimit(0.0f, 1.0f, wetLevel.load()); - const float dry = juce::jlimit(0.0f, 1.0f, dryLevel.load()); + const float legacyWet = juce::jlimit(0.0f, 1.0f, wetLevel.load()); + const float legacyDryGain = juce::jlimit(0.0f, 1.0f, dryLevel.load()); for (int i = 0; i < count; ++i) { + const float rampedWet = standaloneWetRamp.getNextValue(), rampedDry = standaloneDryRamp.getNextValue(); + const float wet = correctedControls ? rampedWet : legacyWet; + const float dry = correctedControls ? rampedDry : legacyDryGain; const float left = additionalDry.getSample(0, i), right = additionalDry.getSample(channels - 1, i); - auto extra = additionalSpaces.process(left, right); - const auto spring=springSpace.process(left,right);for(size_t channel=0;channel(i)]; + auto extra = additionalSpaces.process(left, right, returnBlend); + const float springReturn=BuiltInReverbReturnLevels::gain(4,returnBlend);auto spring=springSpace.process(left,right,springReturn);if(springReturn!=1)for(size_t channel=0;channel<2;++channel)spring[channel]*=springReturn;for(size_t channel=0;channelprocess(left,right); + const auto retroOutput=retroSpace->process(left,right,returnBlend); for(size_t channel=0;channel= 1.5f && engineVersion.load() < 2.5f ? dryRamp.getNextValue() : legacyDryStart + (legacyDryEnd-legacyDryStart)*static_cast(i+1)/static_cast(count); std::array drySignal {}, mixed {}; @@ -5918,14 +6018,139 @@ void OpenStudioReverb::processBlock(juce::AudioBuffer& buffer, juce::Midi if (workflowActive) for (int ch=0;ch(ch)]+processedWet[static_cast(ch)]); } + compatibilityCoveredSamples = compatibilityFullyCovered + && routing == configuredRouting && canMaskCompatibility(routing) + ? juce::jmin(coverageProofSamples, compatibilityCoveredSamples + count) : 0; } retiringReverbTailSeconds.store(std::max({studioSpace.retiringTailSeconds(),plateSpace.retiringTailSeconds(),modalPlate.retiringTailSeconds(),vintageSpace.retiringTailSeconds(),driftingSpace.retiringTailSeconds(),clearSpace.retiringTailSeconds(),retroSpace->retiringTailSeconds(),spatialSpace.retiringTailSeconds(),ambientSpace.retiringTailSeconds(),echoRoom.retiringTailSeconds(),springSpace.retiringTailSeconds(),convolutionRetirement.retiringTailSeconds(),additionalSpaces.retiringTailSeconds()}),std::memory_order_relaxed); workflowReduction.store(workflow.gainReductionDb()); + // Final gain follows every dry/wet, ducking and auxiliary IR path. Unity is + // an exact bypass; one sample ramp is shared by all outputs and host blocks. + const float trimDb = outputTrimDb.load(std::memory_order_relaxed); + smoothedStandaloneOutputGain.setTargetValue(juce::Decibels::decibelsToGain( + std::isfinite(trimDb) ? juce::jlimit(-60.0f, 12.0f, trimDb) : 0.0f)); + if (smoothedStandaloneOutputGain.isSmoothing()) + { + for (int sample = 0; sample < meterSamples; ++sample) + { + const float gain = smoothedStandaloneOutputGain.getNextValue(); + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + buffer.setSample(channel, sample, buffer.getSample(channel, sample) * gain); + } + } + else if (const float gain = smoothedStandaloneOutputGain.getCurrentValue(); gain != 1.0f) + buffer.applyGain(gain); sanitizeBuiltInBuffer(buffer, 4.0f); } +OpenStudioReverb::CompatibilityRouting OpenStudioReverb::compatibilityRouting() const noexcept +{ + return {{algorithm.load(), engineVersion.load(), plateEngine.load(), plateCharacter.load(), + roomCharacter.load(), studioEngines[0].load(), studioEngines[1].load(), + studioEngines[2].load(), studioEngines[3].load(), studioEngines[4].load(), + springControls[0].load(), shimmerVoiceEngine.load(), rackVoice.load(), + rackPadMode.load(), tailSpillover.load(), reverbCharacterMode.load(), + static_cast(getMainBusNumOutputChannels())}, + convolutionSpace.publicationRevision()}; +} + +bool OpenStudioReverb::canMaskCompatibility(const CompatibilityRouting& routing) const noexcept +{ + if (!standaloneBanking) return false; + for (const float value : routing.values) if (!std::isfinite(value)) return false; + const float selected = routing.values[0]; + if (selected < 0 || selected >= standaloneTypeCount || selected != std::floor(selected)) return false; + const int type = static_cast(selected); + if (type == 0 || type == 1 || type == 3) + return routing.values[static_cast(type == 3 ? 7 : 5 + type)] >= .5f; + if (type == 2) return routing.values[2] >= .5f; + return true; +} + +void OpenStudioReverb::resetCompatibilitySleepProof() noexcept +{ + compatibilityCoveredSamples = 0; + compatibilityRoutingKnown = false; + compatibilitySleeping = false; +} + +void OpenStudioReverb::advanceCompatibilitySmoothers(int samples) noexcept +{ + const auto unitTarget = [] (auto& smoother, const std::atomic& parameter) { + smoother.setTargetValue(juce::jlimit(0.0f, 1.0f, parameter.load(std::memory_order_relaxed))); + }; + unitTarget(smoothedWetLevel, wetLevel); + unitTarget(smoothedDryLevel, dryLevel); + unitTarget(smoothedEarlyLevel, earlyLevel); + unitTarget(smoothedInputSend, inputSend); + unitTarget(smoothedShimmerAmount, shimmerAmount); + const bool processShimmer = smoothedShimmerAmount.getTargetValue() > .0001f + || smoothedShimmerAmount.getCurrentValue() > .0001f || smoothedShimmerAmount.isSmoothing(); + const float selectedEngine = engineVersion.load(std::memory_order_relaxed); + const bool v2 = selectedEngine >= 1.5f && selectedEngine < 2.5f; + const bool v3 = selectedEngine >= 2.5f; + smoothedInputSend.skip(samples); + if (!v2) + { + smoothedWetLevel.skip(samples); + smoothedDryLevel.skip(samples); + smoothedEarlyLevel.skip(samples); + } + if (v2 || v3) + { + smoothedV2Width.setTargetValue(juce::jlimit(0.0f, 1.25f, width.load())); + unitTarget(smoothedV2Ducking, ducking); + unitTarget(smoothedV2Movement, movement); + unitTarget(smoothedV2EarlyLate, earlyLate); + unitTarget(smoothedV2Diffusion, diffusion); + } + if (v2) unitTarget(smoothedV2ShimmerRegen, shimmerRegen); + if (v3) + { + smoothedV5AlternativeVoiceBlend.setTargetValue(selectedEngine >= kReverbV5SelectionFloor + && sanitizeReverbV5VoiceIndex(rackVoice.load()) != OpenStudioNAMRack::studioReverbVoice ? 1.0f : 0.0f); + unitTarget(smoothedV3PadMode, rackPadMode); + const float tone = juce::jlimit(0.0f, 1.0f, (1.0f - damping.load()) / .82f); + smoothedV3LoopDampingCoefficient.setTargetValue(1.0f - std::exp( + -juce::MathConstants::twoPi * (3200.0f + tone * 7800.0f) + / static_cast(juce::jmax(1.0, cachedSampleRate)))); + smoothedV3PreDelayMorph.skip(samples); + smoothedV4TankSizeMorph.skip(samples); + smoothedV3WetFilterMorph.skip(samples); + } + // V2 consumes gains sample by sample. Repeating that progression preserves + // its floating-point ramp endpoints for later audible compatibility wakes; + // SmoothedValue::skip() can round differently from repeated getNextValue(). + for (int sample = 0; sample < samples; ++sample) + { + if (processShimmer) smoothedShimmerAmount.getNextValue(); + if (v2) + { + smoothedWetLevel.getNextValue(); + smoothedDryLevel.getNextValue(); + smoothedEarlyLevel.getNextValue(); + smoothedV2ShimmerRegen.getNextValue(); + } + if (v2 || v3) + { + smoothedV2Width.getNextValue(); + smoothedV2Ducking.getNextValue(); + smoothedV2Movement.getNextValue(); + smoothedV2EarlyLate.getNextValue(); + smoothedV2Diffusion.getNextValue(); + } + if (v3) + { + smoothedV5AlternativeVoiceBlend.getNextValue(); + smoothedV3PadMode.getNextValue(); + smoothedV3LoopDampingCoefficient.getNextValue(); + } + } +} + void OpenStudioReverb::processSelectedEngine(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { + ++compatibilityProcessingCalls; const float selectedEngine = engineVersion.load(std::memory_order_relaxed); if (selectedEngine < 1.5f) @@ -7136,7 +7361,9 @@ void OpenStudioReverb::processV2Block(juce::AudioBuffer& buffer, v2DiffusionOffsets[lineIndex] + writeIndex); const float delayed = - v2DiffusionPool[poolIndex]; + !v2DiffusionHistoryGuardsEnabled + || v2ValidDiffusionHistorySamples[lineIndex] >= capacity + ? v2DiffusionPool[poolIndex] : 0.0f; const float output = delayed - coefficient * input; @@ -7148,6 +7375,8 @@ void OpenStudioReverb::processV2Block(juce::AudioBuffer& buffer, writeIndex + 1 < capacity ? writeIndex + 1 : 0; + v2ValidDiffusionHistorySamples[lineIndex] = juce::jmin( + capacity, v2ValidDiffusionHistorySamples[lineIndex] + 1); return output; }; @@ -10689,14 +10918,8 @@ void OpenStudioReverb::processV3Block(juce::AudioBuffer& buffer, clearNonFiniteSamples(buffer); } -void OpenStudioReverb::resetTailState() noexcept +void OpenStudioReverb::invalidateCompatibilityHistory() noexcept { - retiringReverbTailSeconds.store(0); - peakHold.reset(); - reconstructedPeakMeter.reset(); - for(auto& peak:inputPeaksDb)peak.store(-100.0f,std::memory_order_relaxed); - for(auto& peak:outputPeaksDb)peak.store(-100.0f,std::memory_order_relaxed); - if (standaloneBanking) { additionalSpaces.reset(); convolutionSpace.reset(); convolutionMotion.reset(); convolutionExtension.reset(); convolutionCrossMotion.reset(); convolutionCrossExtension.reset(); convolutionCrossWorkflow.reset(); convolutionRetirement.reset({convolutionWeight}); plateSpace.reset(); modalPlate.reset(); plateColour.reset(); studioSpace.reset(); vintageSpace.reset(); driftingSpace.reset(); clearSpace.reset(); retroSpace->reset(); spatialSpace.reset(); echoRoom.reset(); ambientSpace.reset(); springSpace.reset(); workflow.reset(); workflowReduction.store(0); } validEarlyHistorySamples = 0; validPreDelayHistorySamples = 0; validLateHistorySamples = 0; @@ -10713,18 +10936,9 @@ void OpenStudioReverb::resetTailState() noexcept // line's write index plus the valid-history count makes old tank samples // unreadable until each addressed location has been overwritten, avoiding // a large full-pool memory sweep in one callback. - // V3 invalidates its delay histories by index/count only. Preserve the V2 - // sweep solely for old states because that diffuser has no history guard. - if (engineVersion.load(std::memory_order_relaxed) - < 2.5f) - { - std::fill( - v2DiffusionPool.begin(), - v2DiffusionPool.end(), - 0.0f); - } v2LateWriteIndices.fill(0); v2DiffusionWriteIndices.fill(0); + v2ValidDiffusionHistorySamples.fill(0); v2LowBandState.fill(0.0f); v2BelowHighBandState.fill(0.0f); for (size_t line = 0; @@ -10781,6 +10995,22 @@ void OpenStudioReverb::resetTailState() noexcept v3WetFilterChangePending = false; smoothedV3WetFilterMorph .setCurrentAndTargetValue(0.0f); + v3CachedLowCutHz = -1.0f; + v3CachedHighCutHz = -1.0f; +} + +void OpenStudioReverb::resetTailState() noexcept +{ + standaloneWetRamp.setCurrentAndTargetValue(juce::jlimit(0.0f,1.0f,wetLevel.load())); + standaloneDryRamp.setCurrentAndTargetValue(juce::jlimit(0.0f,1.0f,dryLevel.load())); + retiringReverbTailSeconds.store(0); + peakHold.reset(); + reconstructedPeakMeter.reset(); + for(auto& peak:inputPeaksDb)peak.store(-100.0f,std::memory_order_relaxed); + for(auto& peak:outputPeaksDb)peak.store(-100.0f,std::memory_order_relaxed); + if (standaloneBanking) { additionalSpaces.reset(); convolutionSpace.reset(); convolutionMotion.reset(); convolutionExtension.reset(); convolutionCrossMotion.reset(); convolutionCrossExtension.reset(); convolutionCrossWorkflow.reset(); convolutionRetirement.reset({convolutionWeight}); plateSpace.reset(); modalPlate.reset(); plateColour.reset(); studioSpace.reset(); vintageSpace.reset(); driftingSpace.reset(); clearSpace.reset(); retroSpace->reset(); spatialSpace.reset(); echoRoom.reset(); ambientSpace.reset(); springSpace.reset(); workflow.reset(); workflowReduction.store(0); } + resetCompatibilitySleepProof(); + invalidateCompatibilityHistory(); smoothedWetLevel.setCurrentAndTargetValue(0.0f); smoothedEarlyLevel.setCurrentAndTargetValue(0.0f); smoothedInputSend.setCurrentAndTargetValue( @@ -10906,6 +11136,7 @@ void OpenStudioReverb::resetTailState() noexcept void OpenStudioReverb::releaseResources() { + resetCompatibilitySleepProof(); preDelayLineL.reset(); preDelayLineR.reset(); wetLowCutL.reset(); @@ -10966,6 +11197,7 @@ void OpenStudioReverb::releaseResources() v3PadCachedDecaySeconds = -1.0f; v2LateWriteIndices.fill(0); v2DiffusionWriteIndices.fill(0); + v2ValidDiffusionHistorySamples.fill(0); v2LowBandState.fill(0.0f); v2BelowHighBandState.fill(0.0f); v2ShimmerPreLowPassState.fill(0.0f); @@ -11423,6 +11655,12 @@ void OpenStudioReverb::getStateInformation(juce::MemoryBlock& destData) state.setProperty("nonlinearHold", nonlinearHold.load(), nullptr); state.setProperty("positionedHold", positionedHold.load(), nullptr); state.setProperty("reconstructedPeaks", reconstructedPeaks.load(), nullptr); + state.setProperty("outputTrimDb", outputTrimDb.load(), nullptr); + state.setProperty("reverbCharacterMode", reverbCharacterMode.load(), nullptr); + state.setProperty("reverbReturnMode", reverbReturnMode.load(), nullptr); + state.setProperty("shimmerScheduling", shimmerScheduling.load(), nullptr); + state.setProperty("ambBody", ambBody.load(), nullptr); + state.setProperty("reverbControlMode", reverbControlMode.load(), nullptr); state.setProperty("shimmerVoiceEngine", shimmerVoiceEngine.load(), nullptr); state.setProperty("shimmerHold",shimmerHold.load(),nullptr); state.setProperty("tailSpillover",tailSpillover.load(),nullptr); @@ -11506,6 +11744,18 @@ void OpenStudioReverb::restoreStateTree(const juce::ValueTree& tree, bool restor mixLock.store(static_cast(tree.getProperty("mixLock", 0.0f))); insertWet.store(static_cast(tree.getProperty("insertWet", 0.33f))); insertDry.store(static_cast(tree.getProperty("insertDry", 0.7f))); + const float storedOutputTrim = static_cast(tree.getProperty("outputTrimDb", 0.0f)); + outputTrimDb.store(std::isfinite(storedOutputTrim) ? juce::jlimit(-60.0f, 12.0f, storedOutputTrim) : 0.0f); + const float storedCharacterMode = static_cast(tree.getProperty("reverbCharacterMode", 0.0f)); + reverbCharacterMode.store(std::isfinite(storedCharacterMode) && storedCharacterMode >= .5f ? 1.0f : 0.0f); + const float storedReturnMode = static_cast(tree.getProperty("reverbReturnMode", 0.0f)); + reverbReturnMode.store(std::isfinite(storedReturnMode) && storedReturnMode >= .5f ? 1.0f : 0.0f); + const float storedScheduling = static_cast(tree.getProperty("shimmerScheduling", 0.0f)); + shimmerScheduling.store(std::isfinite(storedScheduling) && storedScheduling >= .5f ? 1.0f : 0.0f); + const float storedControls = static_cast(tree.getProperty("reverbControlMode", 0.0f)); + reverbControlMode.store(std::isfinite(storedControls) && storedControls >= .5f ? 1.0f : 0.0f); + const float storedBody = static_cast(tree.getProperty("ambBody", 0.0f)); + ambBody.store(std::isfinite(storedBody) ? juce::jlimit(0.0f, 1.0f, storedBody) : 0.0f); for (int bank = 0; bank < standaloneTypeCount; ++bank) for (int control = 0; control < bankControlCount; ++control) { @@ -11751,6 +12001,8 @@ OpenStudioChorus::OpenStudioChorus() for (int v = 0; v < maxVoices; ++v) { lfoPhase[v] = static_cast(v) * juce::MathConstants::twoPi / static_cast(maxVoices); + ensemblePhaseL[static_cast(v)] = lfoPhase[v] * .37f; + ensemblePhaseR[static_cast(v)] = lfoPhase[v] * .41f; sampleHoldTarget[static_cast(v)] = nextSampleAndHoldValue(); sampleHoldValue[static_cast(v)] = sampleHoldTarget[static_cast(v)]; @@ -11759,6 +12011,7 @@ OpenStudioChorus::OpenStudioChorus() void OpenStudioChorus::prepareToPlay(double sampleRate, int samplesPerBlock) { + retainedFeedback.store(0); cachedSampleRate = sampleRate; juce::ignoreUnused(samplesPerBlock, getModulationSineLut()); @@ -11951,6 +12204,16 @@ void OpenStudioChorus::prepareToPlay(double sampleRate, int samplesPerBlock) alternateWetHighCutL, alternateWetHighCutR, pendingHighCutCoefficients); + // Allocate the final IIR order here, including inactive morph banks. + // JUCE otherwise lazily resizes state on the first processSample(). + wetLowCutL.reset(); + wetLowCutR.reset(); + wetHighCutL.reset(); + wetHighCutR.reset(); + alternateWetLowCutL.reset(); + alternateWetLowCutR.reset(); + alternateWetHighCutL.reset(); + alternateWetHighCutR.reset(); smoothedWetFilterMorph.reset(sampleRate, 0.015); smoothedWetFilterMorph.setCurrentAndTargetValue(0.0f); wetFiltersUseAlternate = false; @@ -11968,6 +12231,8 @@ void OpenStudioChorus::prepareToPlay(double sampleRate, int samplesPerBlock) lfoPhase[voice] = static_cast(voice) * juce::MathConstants::twoPi / static_cast(maxVoices); + ensemblePhaseL[static_cast(voice)] = lfoPhase[voice] * .37f; + ensemblePhaseR[static_cast(voice)] = lfoPhase[voice] * .41f; sampleHoldTarget[static_cast(voice)] = nextSampleAndHoldValue(); sampleHoldValue[static_cast(voice)] = @@ -11989,6 +12254,8 @@ float OpenStudioChorus::nextSampleAndHoldValue() noexcept void OpenStudioChorus::advanceLFO(int voice, float phaseIncrement) noexcept { const int voiceIndex = juce::jlimit(0, maxVoices - 1, voice); + ensemblePhaseL[static_cast(voiceIndex)] = wrapModulationPhase(ensemblePhaseL[static_cast(voiceIndex)] + phaseIncrement * .37f); + ensemblePhaseR[static_cast(voiceIndex)] = wrapModulationPhase(ensemblePhaseR[static_cast(voiceIndex)] + phaseIncrement * .41f); auto& phase = lfoPhase[voiceIndex]; phase += phaseIncrement; if (phase >= juce::MathConstants::twoPi) @@ -12100,6 +12367,7 @@ float OpenStudioChorus::resolveLFORate() noexcept void OpenStudioChorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { + retainedFeedback.store(juce::jmax(retainedFeedback.load(), std::abs(fbAmount.load())), std::memory_order_relaxed); juce::ignoreUnused(midi); juce::ScopedNoDenormals noDenormals; @@ -12703,7 +12971,7 @@ void OpenStudioChorus::processBlock(juce::AudioBuffer& buffer, juce::Midi currentRandomness); const float ensembleOffset = characterIndex == 1 ? lookupModulationSine( - lfoPhase[v] * 0.37f + (signalPath.load(std::memory_order_relaxed) >= .5f ? ensemblePhaseL[static_cast(v)] : lfoPhase[v] * 0.37f) + static_cast(v) * 1.61f) * 0.18f : 0.0f; @@ -12767,7 +13035,7 @@ void OpenStudioChorus::processBlock(juce::AudioBuffer& buffer, juce::Midi const float decorrelatedEnsembleOffsetR = characterIndex == 1 ? lookupModulationSine( - lfoPhase[v] * 0.41f + (signalPath.load(std::memory_order_relaxed) >= .5f ? ensemblePhaseR[static_cast(v)] : lfoPhase[v] * 0.41f) + static_cast(v) * 1.37f + phaseOffset) * 0.18f @@ -13116,6 +13384,8 @@ void OpenStudioChorus::releaseResources() { lfoPhase[voice] = static_cast(voice) * juce::MathConstants::twoPi / static_cast(maxVoices); + ensemblePhaseL[static_cast(voice)] = lfoPhase[voice] * .37f; + ensemblePhaseR[static_cast(voice)] = lfoPhase[voice] * .41f; sampleHoldTarget[static_cast(voice)] = nextSampleAndHoldValue(); sampleHoldValue[static_cast(voice)] = @@ -13125,23 +13395,18 @@ void OpenStudioChorus::releaseResources() double OpenStudioChorus::getTailLengthSeconds() const { - if (mix.load(std::memory_order_relaxed) <= 0.0001f) - return 0.0; - - const auto currentMode = static_cast(juce::jlimit( - 0, - 2, - static_cast( - std::round(mode.load(std::memory_order_relaxed))))); - // Ensemble chorus reaches just under 24 ms of delayed history. Phaser has - // no delay line, but its all-pass/feedback state benefits from a - // conservative short render tail in standalone use. - return currentMode == Mode::Phaser ? 0.25 : 0.03; + // Include worst-case 26ms Ensemble taps, feedback circulation and the + // 20Hz wet-filter states. Phaser's 12 stages fit this conservative bound. + // Keep a small bound at Mix=0: a live fade may still drain stored history. + const double feedback = juce::jlimit(0.0, .95, juce::jmax(std::abs(static_cast(fbAmount.load())), static_cast(retainedFeedback.load()))); + const double cycles = feedback > .0001 ? std::ceil(std::log(1e-8) / std::log(feedback)) : 1; + return .5 + .032 * (1 + cycles); } void OpenStudioChorus::getStateInformation(juce::MemoryBlock& destData) { saveParamsToMemory(destData, "OpenStudioChorus", { + { "signalPath", signalPath.load() }, { "mode", mode.load() }, { "rate", rate.load() }, { "depth", depth.load() }, @@ -13162,9 +13427,11 @@ void OpenStudioChorus::getStateInformation(juce::MemoryBlock& destData) void OpenStudioChorus::setStateInformation(const void* data, int sizeInBytes) { + const float previousSignalPath = signalPath.load(); auto tree = loadParamsFromMemory(data, sizeInBytes, "OpenStudioChorus"); if (!tree.isValid()) return; + signalPath.store(static_cast(tree.getProperty("signalPath", 0.0f)) >= .5f ? 1.0f : 0.0f); mode = static_cast((double)tree.getProperty("mode", 0.0)); rate = static_cast((double)tree.getProperty("rate", 1.0)); @@ -13191,6 +13458,9 @@ void OpenStudioChorus::setStateInformation(const void* data, int sizeInBytes) mixLaw = static_cast( static_cast( tree.getProperty("mixLaw", 0.0))); + + if (previousSignalPath != signalPath.load() && getSampleRate() > 0 && getBlockSize() > 0) + prepareToPlay(getSampleRate(), getBlockSize()); } bool OpenStudioChorus::isBusesLayoutSupported(const BusesLayout& layouts) const @@ -13214,6 +13484,7 @@ OpenStudioSaturator::OpenStudioSaturator(bool shouldUseLowLatencyOversampling) .withOutput("Output", juce::AudioChannelSet::stereo(), true)), useLowLatencyOversampling(shouldUseLowLatencyOversampling) { + signalPath.store(shouldUseLowLatencyOversampling ? 0.0f : 1.0f); } void OpenStudioSaturator::setOversamplingEnabled(bool enabled) @@ -13313,7 +13584,7 @@ void OpenStudioSaturator::prepareToPlay(double sampleRate, int samplesPerBlock) toneFilterR.coefficients = coeffs; prepareRealtimeFilterLut(toneCoefficientLut, sampleRate, 200.0f, 20000.0f, false); lastLowCutFreq = juce::jlimit(20.0f, 1000.0f, lowCutFreq.load()); - auto lowCutCoeffs = juce::dsp::IIR::Coefficients::makeHighPass(sampleRate, safeFilterFrequency(sampleRate, lastLowCutFreq)); + auto lowCutCoeffs = juce::dsp::IIR::Coefficients::makeHighPass(sampleRate, static_cast(safeFilterFrequency(sampleRate, lastLowCutFreq))); lowCutFilterL.coefficients = lowCutCoeffs; lowCutFilterR.coefficients = lowCutCoeffs; prepareRealtimeFilterLut(lowCutCoefficientLut, sampleRate, 20.0f, 1000.0f, true); @@ -13342,6 +13613,12 @@ void OpenStudioSaturator::prepareToPlay(double sampleRate, int samplesPerBlock) toneCoefficientsSmoothing = false; lowCutCoefficientsSmoothing = false; + // Allocate the final IIR order here, including inactive morph banks. + // JUCE otherwise lazily resizes state on the first processSample(). + toneFilterL.reset(); + toneFilterR.reset(); + lowCutFilterL.reset(); + lowCutFilterR.reset(); const auto oversamplingFilterType = useLowLatencyOversampling ? juce::dsp::Oversampling::filterHalfBandPolyphaseIIR : juce::dsp::Oversampling::filterHalfBandFIREquiripple; @@ -13573,7 +13850,8 @@ float OpenStudioSaturator::processSample( case SatType::Foldback: { // Foldback: controlled creative distortion, level bounded for safety. - const auto shape = [] (float value) + const bool corrected = signalPath.load(std::memory_order_relaxed) >= .5f; + const auto shape = [corrected] (float value) { float shaped = value; constexpr float threshold = 0.78f; @@ -13586,7 +13864,7 @@ float OpenStudioSaturator::processSample( - std::trunc(phase / period) * period; shaped = std::abs( - wrapped + (corrected ? std::abs(wrapped) : wrapped) - threshold * 2.0f) - threshold; return juce::jlimit(-1.0f, 1.0f, shaped); @@ -13781,6 +14059,21 @@ void OpenStudioSaturator::processBlock(juce::AudioBuffer& buffer, juce::M smoothedTopologyGain.setTargetValue(1.0f); topologyTransitionStage = 2; } + // End the old engine exactly at the native-sample fade boundary. Recursive + // slices are non-owning, bounded to the two fade endpoints, and allocate no + // audio storage. Oversamplers retain their history across slices. + const int remainingFade = smoothedTopologyGain.getNumSamplesToReachTarget(); + if (signalPath.load(std::memory_order_relaxed) >= .5f && remainingFade > 0 && remainingFade < numSamples) + { + std::array pointers {}; + for (int ch = 0; ch < numChannels; ++ch) pointers[static_cast(ch)] = buffer.getWritePointer(ch); + juce::AudioBuffer first(pointers.data(), numChannels, remainingFade); + processBlock(first, midi); + for (int ch = 0; ch < numChannels; ++ch) pointers[static_cast(ch)] += remainingFade; + juce::AudioBuffer rest(pointers.data(), numChannels, numSamples - remainingFade); + processBlock(rest, midi); + return; + } const auto type = static_cast(activeSaturationType); const float asymmetryTarget = @@ -13980,8 +14273,7 @@ void OpenStudioSaturator::processBlock(juce::AudioBuffer& buffer, juce::M float drivenLeft = dryLeft; if (lowCutBeforeSaturation) drivenLeft = - lowCutFilterL.processSample( - drivenLeft); + static_cast(lowCutFilterL.processSample(drivenLeft)); drivenLeft *= driveLinear; float processedLeft = shapeSample( drivenLeft, @@ -13994,8 +14286,7 @@ void OpenStudioSaturator::processBlock(juce::AudioBuffer& buffer, juce::M if (! lowCutBeforeSaturation) { processedLeft = - lowCutFilterL.processSample( - processedLeft); + static_cast(lowCutFilterL.processSample(processedLeft)); } processedLeft *= outGainLinear; processedLeft = @@ -14025,8 +14316,7 @@ void OpenStudioSaturator::processBlock(juce::AudioBuffer& buffer, juce::M if (lowCutBeforeSaturation) { drivenRight = - lowCutFilterR.processSample( - drivenRight); + static_cast(lowCutFilterR.processSample(drivenRight)); } drivenRight *= driveLinear; float processedRight = shapeSample( @@ -14040,8 +14330,7 @@ void OpenStudioSaturator::processBlock(juce::AudioBuffer& buffer, juce::M if (! lowCutBeforeSaturation) { processedRight = - lowCutFilterR.processSample( - processedRight); + static_cast(lowCutFilterR.processSample(processedRight)); } processedRight *= outGainLinear; processedRight = @@ -14104,10 +14393,10 @@ void OpenStudioSaturator::processBlock(juce::AudioBuffer& buffer, juce::M { advanceLowCutCoefficients(); wetLeft[sample] = - lowCutFilterL.processSample(wetLeft[sample]); + static_cast(lowCutFilterL.processSample(wetLeft[sample])); if (wetRight != nullptr) wetRight[sample] = - lowCutFilterR.processSample(wetRight[sample]); + static_cast(lowCutFilterR.processSample(wetRight[sample])); } } @@ -14156,7 +14445,7 @@ void OpenStudioSaturator::processBlock(juce::AudioBuffer& buffer, juce::M smoothedTopologyGain.getNextValue(); float processedLeft = processTone(wetLeft[sample],0); if (! lowCutBeforeSaturation) - processedLeft = lowCutFilterL.processSample(processedLeft); + processedLeft = static_cast(lowCutFilterL.processSample(processedLeft)); processedLeft *= outGainLinear; processedLeft = std::isfinite(processedLeft) ? juce::jlimit(-2.5f, 2.5f, processedLeft) @@ -14175,7 +14464,7 @@ void OpenStudioSaturator::processBlock(juce::AudioBuffer& buffer, juce::M processTone(wetRight[sample],1); if (! lowCutBeforeSaturation) processedRight = - lowCutFilterR.processSample(processedRight); + static_cast(lowCutFilterR.processSample(processedRight)); processedRight *= outGainLinear; processedRight = std::isfinite(processedRight) ? juce::jlimit(-2.5f, 2.5f, processedRight) @@ -14392,6 +14681,7 @@ void OpenStudioSaturator::publishColourMeters(const juce::AudioBuffer& bu void OpenStudioSaturator::getStateInformation(juce::MemoryBlock& destData) { saveParamsToMemory(destData, "OpenStudioSaturator", { + { "signalPath", signalPath.load() }, { "colourEngine",colourEngine.load() }, { "inputTrim",inputTrim.load() }, { "boostDrive",boostDrive.load() }, { "driveCompensation",driveCompensation.load() }, { "colourTone",colourTone.load() }, { "cornerBump",cornerBump.load() }, { "colourDynamics",colourDynamics.load() }, { "steepCut",steepCut.load() }, { "satType", satType.load() }, @@ -14409,9 +14699,11 @@ void OpenStudioSaturator::getStateInformation(juce::MemoryBlock& destData) void OpenStudioSaturator::setStateInformation(const void* data, int sizeInBytes) { + const float previousSignalPath = signalPath.load(); auto tree = loadParamsFromMemory(data, sizeInBytes, "OpenStudioSaturator"); if (!tree.isValid()) return; + signalPath.store(static_cast(tree.getProperty("signalPath", 0.0f)) >= .5f ? 1.0f : 0.0f); const auto readColour=[&](const char* id,float fallback,float minimum,float maximum){const float value=static_cast(tree.getProperty(id,fallback));return std::isfinite(value)?juce::jlimit(minimum,maximum,value):fallback;}; colourEngine.store(readColour("colourEngine",0,0,5));inputTrim.store(readColour("inputTrim",0,-24,24));boostDrive.store(readColour("boostDrive",0,0,1));driveCompensation.store(readColour("driveCompensation",1,0,1)); @@ -14436,6 +14728,9 @@ void OpenStudioSaturator::setStateInformation(const void* data, int sizeInBytes) "oversamplingEnabled", restoredOversamplingMode >= 0.5f ? 1.0 : 0.0)) >= 0.5; setOversamplingEnabled(restoredOversamplingEnabled); + + if (previousSignalPath != signalPath.load() && getSampleRate() > 0 && getBlockSize() > 0) + prepareToPlay(getSampleRate(), getBlockSize()); } // ============================================================================ @@ -14870,21 +15165,6 @@ void OpenStudioNAMRack::NAMResamplerState::reset() noexcept nextSourceRemainder = 0; } -float OpenStudioNAMRack::NAMResamplerState::sampleAt(std::int64_t absoluteIndex) const noexcept -{ - if (absoluteIndex < 0 || absoluteIndex >= totalInputSamples) - return 0.0f; - - const auto age = totalInputSamples - 1 - absoluteIndex; - if (age < 0 || age >= namResamplerHistorySize) - return 0.0f; - - int index = writeIndex - 1 - static_cast(age); - while (index < 0) - index += namResamplerHistorySize; - return history[static_cast(index)]; -} - float OpenStudioNAMRack::NAMResamplerState::interpolate(double sourcePosition, const NAMResamplerKernel& kernel) const noexcept { @@ -14896,13 +15176,39 @@ float OpenStudioNAMRack::NAMResamplerState::interpolate(double sourcePosition, namResamplerKernelPhases - 1, static_cast(fraction * static_cast(namResamplerKernelPhases))); const auto phaseOffset = static_cast(phase * namResamplerKernelTaps); + const auto firstAbsoluteIndex = baseIndex - (half - 1); + const auto oldestAbsoluteIndex = juce::jmax( + 0, totalInputSamples - namResamplerHistorySize); + const int firstValidTap = static_cast(juce::jlimit( + 0, namResamplerKernelTaps, oldestAbsoluteIndex - firstAbsoluteIndex)); + const int endValidTap = static_cast(juce::jlimit( + firstValidTap, namResamplerKernelTaps, totalInputSamples - firstAbsoluteIndex)); double result = 0.0; - for (int tap = 0; tap < namResamplerKernelTaps; ++tap) - { - const int sampleOffset = tap - (half - 1); - result += static_cast(sampleAt(baseIndex + sampleOffset)) - * static_cast(kernel.coefficients[phaseOffset + static_cast(tap)]); - } + int tap = 0; + // Retain the old sequential double accumulation, including zero-padded + // startup/future taps. Only the repeated per-tap history lookup changes. + for (; tap < firstValidTap; ++tap) + result += 0.0 * static_cast(kernel.coefficients[phaseOffset + static_cast(tap)]); + if (tap < endValidTap) + { + int historyIndex = writeIndex - static_cast( + totalInputSamples - (firstAbsoluteIndex + tap)); + if (historyIndex < 0) + historyIndex += namResamplerHistorySize; + // Forty-eight taps fit in the 128-sample ring, so at most two + // contiguous spans are needed; no bounds/age/wrap work per tap. + const int firstSpanEnd = juce::jmin( + endValidTap, tap + namResamplerHistorySize - historyIndex); + for (; tap < firstSpanEnd; ++tap, ++historyIndex) + result += static_cast(history[static_cast(historyIndex)]) + * static_cast(kernel.coefficients[phaseOffset + static_cast(tap)]); + historyIndex = 0; + for (; tap < endValidTap; ++tap, ++historyIndex) + result += static_cast(history[static_cast(historyIndex)]) + * static_cast(kernel.coefficients[phaseOffset + static_cast(tap)]); + } + for (; tap < namResamplerKernelTaps; ++tap) + result += 0.0 * static_cast(kernel.coefficients[phaseOffset + static_cast(tap)]); return static_cast(result); } @@ -15777,30 +16083,30 @@ static float applyNAMChaosC2FirmShoulder(float input) noexcept // transition has matching value, first derivative, and second derivative // at both boundaries, avoiding the abrupt higher-order corner of the // retired cubic hard-knee cell. - constexpr float knee = 0.48f; - constexpr float limit = 1.0f; - const float magnitude = std::abs(input); + constexpr double knee = 0.48; + constexpr double limit = 1.0; + const double magnitude = std::abs(static_cast(input)); if (magnitude <= knee) return input; if (magnitude >= limit) - return std::copysign(limit, input); + return std::copysign(1.0f, input); - const float t = (magnitude - knee) / (limit - knee); - const float t2 = t * t; - const float t3 = t2 * t; - const float transition = - t + 4.0f * t3 - 7.0f * t3 * t + 3.0f * t3 * t2; + const double t = (magnitude - knee) / (limit - knee); + const double t2 = t * t; + const double t3 = t2 * t; + const double transition = + t + 4.0 * t3 - 7.0 * t3 * t + 3.0 * t3 * t2; return std::copysign( - knee + (limit - knee) * transition, + static_cast(knee + (limit - knee) * transition), input); } -static float getNAMStableLogCosh(float input) noexcept +static double getNAMStableLogCosh(double input) noexcept { - const float magnitude = std::abs(input); + const double magnitude = std::abs(input); return magnitude - + std::log1p(std::exp(-2.0f * magnitude)) - - std::log(2.0f); + + std::log1p(std::exp(-2.0 * magnitude)) + - std::log(2.0); } static float applyNAMFirstOrderTanhADAA( @@ -15810,65 +16116,71 @@ static float applyNAMFirstOrderTanhADAA( float& previousInput, bool& statePrimed) noexcept { - const float direct = std::tanh(input + bias) - outputOffset; + const auto direct = [input, bias, outputOffset] () noexcept + { return std::tanh(input + bias) - outputOffset; }; if (! statePrimed) { previousInput = input; statePrimed = true; - return direct; + return direct(); } - const float delta = input - previousInput; - const float tolerance = 1.0e-4f * std::max( - { 1.0f, std::abs(input), std::abs(previousInput) }); - float output = direct; - if (std::abs(delta) > tolerance) - { - const auto antiderivative = [bias, outputOffset] (float value) noexcept - { - return getNAMStableLogCosh(value + bias) - - outputOffset * value; - }; - output = (antiderivative(input) - antiderivative(previousInput)) - / delta; + const double current = input, previous = previousInput; + previousInput = input; + const double delta = current - previous; + if (delta == 0.0) + return direct(); // Preserve the exact constant-input/zero convention. + + double average = 0.0; + if (std::abs(delta) <= 0.125) + { + // The symmetric interval average through fourth order avoids subtracting + // nearly equal primitives. The omitted term is below 6.2e-10 throughout + // this interval, before rounding to float. The old float quotient could + // magnify an input ULP into ~1e-3 of artificial output at small deltas. + const double t = std::tanh(0.5 * (current + previous) + bias); + const double square = t * t, deltaSquared = delta * delta; + average = t + deltaSquared * t * (square - 1.0) / 12.0 + + deltaSquared * deltaSquared * t * (1.0 - square) * (2.0 - 3.0 * square) / 240.0; } else { - output = std::tanh( - 0.5f * (input + previousInput) + bias) - outputOffset; + average = (getNAMStableLogCosh(current + bias) + - getNAMStableLogCosh(previous + bias)) / delta; } - - previousInput = input; - return std::isfinite(output) ? output : direct; + // Keep the existing float offset convention. Exact constant zero is + // guaranteed by the direct branch above, independently of tanhf rounding. + const float output = static_cast(average) - outputOffset; + return std::isfinite(output) ? output : direct(); } -static float getNAMChaosC2FirmShoulderAntiderivative( - float input) noexcept +static double getNAMChaosC2FirmShoulderAntiderivative( + double input) noexcept { - constexpr float knee = 0.48f; - constexpr float limit = 1.0f; - constexpr float width = limit - knee; - const float magnitude = std::abs(input); + constexpr double knee = 0.48; + constexpr double limit = 1.0; + constexpr double width = limit - knee; + const double magnitude = std::abs(input); if (magnitude <= knee) - return 0.5f * magnitude * magnitude; + return 0.5 * magnitude * magnitude; - constexpr float transitionIntegralAtLimit = 0.6f; - const float integralAtLimit = 0.5f * knee * knee + constexpr double transitionIntegralAtLimit = 0.6; + const double integralAtLimit = 0.5 * knee * knee + width * knee + width * width * transitionIntegralAtLimit; if (magnitude >= limit) return integralAtLimit + (magnitude - limit) * limit; - const float t = (magnitude - knee) / width; - const float t2 = t * t; - const float t4 = t2 * t2; - const float t5 = t4 * t; - const float t6 = t5 * t; - const float transitionIntegral = 0.5f * t2 + const double t = (magnitude - knee) / width; + const double t2 = t * t; + const double t4 = t2 * t2; + const double t5 = t4 * t; + const double t6 = t5 * t; + const double transitionIntegral = 0.5 * t2 + t4 - - 1.4f * t5 - + 0.5f * t6; - return 0.5f * knee * knee + - 1.4 * t5 + + 0.5 * t6; + return 0.5 * knee * knee + width * knee * t + width * width * transitionIntegral; } @@ -15878,32 +16190,276 @@ static float applyNAMChaosC2FirmShoulderADAA( float& previousInput, bool& statePrimed) noexcept { - const float direct = applyNAMChaosC2FirmShoulder(input); + const auto direct = [input] () noexcept { return applyNAMChaosC2FirmShoulder(input); }; if (! statePrimed) { previousInput = input; statePrimed = true; - return direct; + return direct(); } - const float delta = input - previousInput; - const float tolerance = 1.0e-4f * std::max( - { 1.0f, std::abs(input), std::abs(previousInput) }); - float output = direct; - if (std::abs(delta) > tolerance) - { - output = (getNAMChaosC2FirmShoulderAntiderivative(input) - - getNAMChaosC2FirmShoulderAntiderivative(previousInput)) + const double current = input, previous = previousInput; + previousInput = input; + const double delta = current - previous; + if (delta == 0.0) return direct(); + if (std::abs(current) <= 0.48 && std::abs(previous) <= 0.48) + return static_cast(0.5 * (current + previous)); + if (current >= 1.0 && previous >= 1.0) return 1.0f; + if (current <= -1.0 && previous <= -1.0) return -1.0f; + + double average = 0.0; + if (std::abs(delta) > 1.0e-4) + { + average = (getNAMChaosC2FirmShoulderAntiderivative(current) + - getNAMChaosC2FirmShoulderAntiderivative(previous)) / delta; } else { - output = applyNAMChaosC2FirmShoulder( - 0.5f * (input + previousInput)); + // The C2 shoulder's maximum second derivative is below 7.58, so the + // midpoint average error here is below 3.2e-9 before float rounding. + average = applyNAMChaosC2FirmShoulder(static_cast(0.5 * (current + previous))); } + const float output = static_cast(average); + return std::isfinite(output) ? output : direct(); +} - previousInput = input; - return std::isfinite(output) ? output : direct; +juce::var OpenStudioNAMRack::runDistortionADAARegression() +{ + // Independent numerical integration of each transfer curve. This oracle + // shares neither production antiderivatives nor the near-delta expansion. + const auto curve = [] (double value, double bias, bool firm) noexcept + { + if (! firm) return std::tanh(value + bias); + const double magnitude = std::abs(value); + if (magnitude <= 0.48) return value; + if (magnitude >= 1.0) return std::copysign(1.0, value); + const double t = (magnitude - 0.48) / 0.52; + return std::copysign(0.48 + 0.52 * (t + 4.0 * std::pow(t, 3) + - 7.0 * std::pow(t, 4) + 3.0 * std::pow(t, 5)), value); + }; + const auto integralAverage = [&curve] (float current, float previous, float bias, bool firm) + { + const double low = juce::jmin(static_cast(current), static_cast(previous)); + const double high = juce::jmax(static_cast(current), static_cast(previous)); + if (low == high) return curve(low, bias, firm); + std::vector cuts { low, high }; + if (firm) + for (const double knee : { -1.0, -0.48, 0.48, 1.0 }) + if (knee > low && knee < high) cuts.push_back(knee); + std::sort(cuts.begin(), cuts.end()); + constexpr std::array nodes { + 0.1834346424956498, 0.5255324099163290, 0.7966664774136267, 0.9602898564975363 }; + constexpr std::array weights { + 0.3626837833783620, 0.3137066458778873, 0.2223810344533745, 0.1012285362903763 }; + double integral = 0.0; + for (size_t section = 1; section < cuts.size(); ++section) + { + const double start = cuts[section - 1], length = cuts[section] - start; + const int pieces = juce::jmax(1, static_cast(std::ceil(length / 0.25))); + const double halfWidth = length / (2.0 * pieces); + for (int piece = 0; piece < pieces; ++piece) + { + const double center = start + (2.0 * piece + 1.0) * halfWidth; + for (size_t index = 0; index < nodes.size(); ++index) + integral += halfWidth * weights[index] * (curve(center - halfWidth * nodes[index], bias, firm) + + curve(center + halfWidth * nodes[index], bias, firm)); + } + } + return integral / (high - low); + }; + const auto process = [] (float input, float bias, bool firm, float& previous, bool& primed) noexcept + { + return firm ? applyNAMChaosC2FirmShoulderADAA(input, previous, primed) + : applyNAMFirstOrderTanhADAA(input, bias, std::tanh(bias), previous, primed); + }; + bool passed = true, exactZero = true, exactConstant = true, exactPriming = true; + bool stateUpdated = true, finiteExtremes = true, dynamicBiasStatePassed = true; + int cases = 0; + double maximumError = 0.0, maximumSymmetryError = 0.0, maximumUlpExcess = 0.0; + juce::var worstCase; + const auto verify = [&] (float input, float previous, float bias, bool firm) + { + float state = previous; bool primed = true; + const float output = process(input, bias, firm, state, primed); + const float offset = firm ? 0.0f : std::tanh(bias); + const double expected = integralAverage(input, previous, bias, firm) - offset; + const double error = std::abs(static_cast(output) - expected); + const double lower = juce::jmin(curve(input, bias, firm), curve(previous, bias, firm)) - offset; + const double upper = juce::jmax(curve(input, bias, firm), curve(previous, bias, firm)) - offset; + stateUpdated = stateUpdated && state == input && primed; + float reversedState = input; bool reversedPrimed = true; + const float reversed = process(previous, bias, firm, reversedState, reversedPrimed); + const double symmetryError = std::abs(static_cast(output) - reversed); + maximumSymmetryError = juce::jmax(maximumSymmetryError, symmetryError); + const float adjacent = std::nextafter(input, std::numeric_limits::infinity()); + float adjacentState = previous; bool adjacentPrimed = true; + const float adjacentOutput = process(adjacent, bias, firm, adjacentState, adjacentPrimed); + const double ulpExcess = std::abs(static_cast(adjacentOutput) - output) + - (firm ? 0.8 : 0.5) * std::abs(static_cast(adjacent) - input); + maximumUlpExcess = juce::jmax(maximumUlpExcess, ulpExcess); + passed = std::isfinite(output) && std::isfinite(adjacentOutput) && error <= 2.0e-7 + && output >= lower - 2.0e-7 && output <= upper + 2.0e-7 + && symmetryError <= 2.0e-7 && ulpExcess <= 4.0e-7 && passed; + if (error > maximumError) + { + maximumError = error; + auto* witness = new juce::DynamicObject(); + witness->setProperty("curve", firm ? "firm" : "tanh"); + witness->setProperty("input", input); witness->setProperty("previous", previous); + witness->setProperty("bias", bias); witness->setProperty("output", output); + witness->setProperty("independentQuadratureAverage", expected); + worstCase = witness; + } + ++cases; + }; + const std::array inputs { -12.0f, -2.0f, -1.0f, -0.9802f, -0.48f, + -0.125f, -0.0001f, -1.0e-7f, 0.0f, 1.0e-7f, 0.0001f, 0.125f, + 0.48f, 0.93718f, 1.0f, 2.0f, 12.0f }; + const std::array deltas { 0.0f, 1.0e-7f, 1.0e-5f, + std::nextafter(1.0e-4f, 0.0f), 1.0e-4f, std::nextafter(1.0e-4f, 1.0f), + std::nextafter(0.125f, 0.0f), 0.125f, std::nextafter(0.125f, 1.0f), 0.2f, 1.0f, 2.0f }; + for (const bool firm : { false, true }) + for (const float bias : { -0.35f, 0.0f, 0.07f, 0.35f }) + { + float zeroState = 0.0f; bool zeroPrimed = false; + for (const float liveBias : { bias, -bias, 0.0f, bias }) + exactZero = process(0.0f, liveBias, firm, zeroState, zeroPrimed) == 0.0f && exactZero; + for (const float input : inputs) + { + float state = -123.0f; bool primed = false; + const float direct = firm ? applyNAMChaosC2FirmShoulder(input) + : std::tanh(input + bias) - std::tanh(bias); + exactPriming = process(input, bias, firm, state, primed) == direct && exactPriming; + exactConstant = process(input, bias, firm, state, primed) == direct && exactConstant; + for (const float delta : deltas) + { + verify(input, input - delta, bias, firm); + verify(input, input + delta, bias, firm); + } + verify(std::nextafter(input, -std::numeric_limits::infinity()), input, bias, firm); + verify(std::nextafter(input, std::numeric_limits::infinity()), input, bias, firm); + } + // Keep a live previous input while bias changes: both endpoints of an + // interval must use the current bias, never a stale previous offset. + float state = -0.72f; bool primed = true; + const std::array liveBiases { bias, -bias, 0.0f, 0.35f, -0.35f }; + size_t biasStep = 0; + for (const float next : { -0.3f, 0.001f, 0.48f, 0.99f, -0.72f }) + { + const float previous = state, liveBias = liveBiases[biasStep++]; + const float output = process(next, liveBias, firm, state, primed); + const double expected = integralAverage(next, previous, liveBias, firm) + - (firm ? 0.0f : std::tanh(liveBias)); + dynamicBiasStatePassed = std::isfinite(output) && std::abs(output - expected) <= 2.0e-7 + && state == next && primed && dynamicBiasStatePassed; + verify(next, previous, liveBias, firm); + } + for (const float input : { -std::numeric_limits::max(), std::numeric_limits::max() }) + { + float previous = 0.0f; bool extremePrimed = true; + const float output = process(input, bias, firm, previous, extremePrimed); + finiteExtremes = std::isfinite(output) && std::abs(output) <= 1.34f && finiteExtremes; + } + } + // Retain the exact one-ULP witnesses that exposed cancellation in the old + // float primitive quotients, in addition to the broader boundary grid. + verify(0.93718f, 0.93707f, 0.07f, false); + verify(-0.9802f, -0.9803f, 0.0f, true); + auto* value = new juce::DynamicObject(); + value->setProperty("cases", cases); value->setProperty("exactZeroIncludingBiasChanges", exactZero); + value->setProperty("exactConstant", exactConstant); value->setProperty("exactFirstSample", exactPriming); + value->setProperty("stateUpdated", stateUpdated); value->setProperty("finiteFloatExtremes", finiteExtremes); + value->setProperty("dynamicBiasStatePassed", dynamicBiasStatePassed); + value->setProperty("maximumQuadratureError", maximumError); value->setProperty("worstCase", worstCase); + value->setProperty("maximumEndpointSwapDifference", maximumSymmetryError); + value->setProperty("maximumUlpStepAboveDerivativeBound", maximumUlpExcess); + auto* result = new juce::DynamicObject(); + result->setProperty("id", "nam_distortion_adaa_numeric_contracts"); + result->setProperty("status", passed && exactZero && exactConstant && exactPriming + && stateUpdated && finiteExtremes && dynamicBiasStatePassed ? "pass" : "fail"); + result->setProperty("detail", "Both production ADAA cells must match independent composite8-point Gauss integration within2e-7, preserve endpoint bounds and swap symmetry, reject artificial one-ULP jumps, preserve exact zero under bias changes and constant/first-sample behavior, and remain finite at float extremes. Includes old/new delta boundaries and firm knees. These numerical contracts do not certify perceived sound quality."); + result->setProperty("value", value); + return result; +} + +juce::var OpenStudioSaturator::runLowCutPrecisionRegression() +{ + juce::Array rows; + bool passed = true; + for (const double nativeRate : { 44100.0, 48000.0, 96000.0 }) + for (const int factor : { 2, 4, 8 }) + { + const double filterRate = nativeRate * factor; + OpenStudioSaturator processor(true); + processor.lowCutFreq.store(20.0f); processor.oversampleMode.store(0.0f); + processor.setPlayConfigDetails(1, 1, filterRate, 128); + processor.prepareToPlay(filterRate, 128); + const auto* coefficients = processor.lowCutFilterL.coefficients->getRawCoefficients(); + const double dcMargin = 1.0 + coefficients[3] + coefficients[4]; + const bool stable = dcMargin > 0.0 && 1.0 - coefficients[3] + coefficients[4] > 0.0 + && 1.0 - coefficients[4] > 0.0; + bool exactZero = processor.lowCutFilterL.processSample(0.0) == 0.0 + && processor.lowCutFilterR.processSample(0.0) == 0.0; + + // Independent bilinear Butterworth design and direct-form-I state. + // Production uses the prepared lookup-table coefficients and JUCE DF-II. + const double tangent = std::tan(juce::MathConstants::pi * 20.0 / filterRate); + const double tangentSquared = tangent * tangent; + const double normalizer = 1.0 / (1.0 + std::sqrt(2.0) * tangent + tangentSquared); + const double a1 = 2.0 * (tangentSquared - 1.0) * normalizer; + const double a2 = (1.0 - std::sqrt(2.0) * tangent + tangentSquared) * normalizer; + double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0; + double maximumReferenceError = 0.0, maximumPerturbation = 0.0; + bool finite = true; + std::uint32_t seed = 0x8a5cd789u; + for (int sample = 0; sample < static_cast(filterRate * 0.5); ++sample) + { + seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; + const float input = static_cast(0.1 * std::sin( + juce::MathConstants::twoPi * 55.0 * sample / filterRate) + + (static_cast(seed) / 2147483648.0 - 1.0) * 0.001); + const float perturbed = std::nextafter(input, std::numeric_limits::infinity()); + const double actual = processor.lowCutFilterL.processSample(input); + const double paired = processor.lowCutFilterR.processSample(perturbed); + const double reference = normalizer * (input - 2.0 * x1 + x2) - a1 * y1 - a2 * y2; + x2 = x1; x1 = input; y2 = y1; y1 = reference; + finite = finite && std::isfinite(actual) && std::isfinite(paired) && std::isfinite(reference); + maximumReferenceError = juce::jmax(maximumReferenceError, std::abs(actual - reference)); + maximumPerturbation = juce::jmax(maximumPerturbation, std::abs(actual - paired)); + } + processor.resetRealtimeStateForEmbeddedBypass(); + exactZero = exactZero && processor.lowCutFilterL.processSample(0.0) == 0.0 + && processor.lowCutFilterR.processSample(0.0) == 0.0; + double finalDcPeak = 0.0; + const int dcSamples = static_cast(filterRate * 0.5); + for (int sample = 0; sample < dcSamples; ++sample) + { + const double output = processor.lowCutFilterL.processSample(0.125); + finite = finite && std::isfinite(output); + if (sample >= dcSamples - 128) finalDcPeak = juce::jmax(finalDcPeak, std::abs(output)); + } + processor.releaseResources(); + exactZero = exactZero && processor.lowCutFilterL.processSample(0.0) == 0.0 + && processor.lowCutFilterR.processSample(0.0) == 0.0; + const bool rowPassed = stable && exactZero && finite && maximumReferenceError < 2.0e-7 + && maximumPerturbation < 2.0e-7 && finalDcPeak < 1.0e-8; + passed = rowPassed && passed; + auto* row = new juce::DynamicObject(); + row->setProperty("nativeRate", nativeRate); row->setProperty("oversamplingFactor", factor); + row->setProperty("actualFilterRate", filterRate); row->setProperty("dcPoleMargin", dcMargin); + row->setProperty("stablePoles", stable); row->setProperty("exactZeroAfterPrepareAndResets", exactZero); + row->setProperty("finite", finite); row->setProperty("maximumIndependentReferenceError", maximumReferenceError); + row->setProperty("maximumOneUlpPerturbation", maximumPerturbation); + row->setProperty("finalDcPeak", finalDcPeak); row->setProperty("passed", rowPassed); + rows.add(row); + } + auto* result = new juce::DynamicObject(); + result->setProperty("id", "nam_saturator_low_cut_precision_contracts"); + result->setProperty("status", passed ? "pass" : "fail"); + result->setProperty("detail", "Actual prepared Saturator 20Hz low-cut coefficients/state must have stable poles, exact fresh/reset silence, match an independent bilinear/direct-form-I reference within 2e-7, avoid amplifying a one-ULP input perturbation above 2e-7 and decay a DC step below 1e-8. Covers 44.1/48/96kHz at 2x/4x/8x. No perceived sound quality is asserted."); + result->setProperty("value", rows); + return result; } static float getNAMEmbeddedDriveOperatingGain( @@ -16460,6 +17016,16 @@ void OpenStudioNAMRack::setChaosSmallSignalNormalizerTarget( == bassInstrumentProfile ? bassInstrumentProfile : guitarInstrumentProfile; + const bool controlChanged = + ! chaosSmallSignalControlInitialized + || boundedMode != chaosSmallSignalLastMode + || boundedDrive != chaosSmallSignalLastDrive + || boundedTone != chaosSmallSignalLastTone + || boundedWeight != chaosSmallSignalLastWeight + || boundedProfile != chaosSmallSignalLastProfile; + if (! immediate && ! controlChanged) + return; + const float requestedScale = resolveChaosSmallSignalScale( static_cast(boundedMode), boundedDrive); if (immediate) @@ -16477,16 +17043,6 @@ void OpenStudioNAMRack::setChaosSmallSignalNormalizerTarget( return; } - const bool controlChanged = - ! chaosSmallSignalControlInitialized - || boundedMode != chaosSmallSignalLastMode - || boundedDrive != chaosSmallSignalLastDrive - || boundedTone != chaosSmallSignalLastTone - || boundedWeight != chaosSmallSignalLastWeight - || boundedProfile != chaosSmallSignalLastProfile; - if (! controlChanged) - return; - chaosSmallSignalLastMode = boundedMode; chaosSmallSignalLastDrive = boundedDrive; chaosSmallSignalLastTone = boundedTone; @@ -16553,6 +17109,9 @@ void OpenStudioNAMRack::skipChaosSmallSignalNormalizer( void OpenStudioNAMRack::synchroniseChaosTopologyWhileBypassed() noexcept { + // This path replaces smoother state directly. The next audible callback + // must resolve its complete target snapshot even if the controls match. + chaosCoefficientCache.valid = false; const float mode = std::isfinite(chaosBlockMode) ? juce::jlimit(0.0f, 2.0f, chaosBlockMode) : 0.0f; @@ -17072,6 +17631,8 @@ OpenStudioNAMRack::OpenStudioNAMRack() .withOutput("Output", juce::AudioChannelSet::stereo(), true)), rackDelay(6.1f) { + rackChorus.signalPath.store(0); + retiredCabIRs.reserve(8); // The Rack owns one selectable shared island around both nonlinear stages. // Precision uses its purpose-built feedback circuit there, while the @@ -17442,10 +18003,22 @@ double OpenStudioNAMRack::getMaximumAutomatedTailLengthSeconds() const | tailAutomationModulator | tailAutomationCab); } +void OpenStudioNAMRack::invalidateEmbeddedDriveCoefficientCaches() noexcept +{ + precisionDriveCoefficientCache.valid = false; + chaosCoefficientCache.valid = false; + precisionDriveVolumeCache.valid = false; + chaosLevelCache.valid = false; + embeddedDriveOperatingGainCache.valid = false; +} + void OpenStudioNAMRack::prepareToPlay(double sampleRate, int samplesPerBlock) { const juce::ScopedLock processorConfigurationLock(getCallbackLock()); const juce::ScopedLock hostPublicationLock(modelHostPublicationLock); + invalidateEmbeddedDriveCoefficientCaches(); + diagnosticPrecisionDriveCoefficientDesignCount.store(0, std::memory_order_relaxed); + diagnosticChaosCoefficientDesignCount.store(0, std::memory_order_relaxed); publishedTempoBpm.store(0.0f, std::memory_order_relaxed); publishedDelayTailSeconds.store(0.0f, std::memory_order_relaxed); namEffectsDspVersion.store( @@ -17738,6 +18311,8 @@ void OpenStudioNAMRack::prepareToPlay(double sampleRate, int samplesPerBlock) const int resampledCapacity = getNAMRackDspFrameCapacity(realtimeBufferCapacity, cachedSampleRate, cachedSampleRate * 4.0); workBuffer.setSize(2, realtimeBufferCapacity, false, false, true); + octaverExcitationGainBuffer.setSize( + 1, realtimeBufferCapacity, false, false, true); namInputBuffer.setSize(2, realtimeBufferCapacity + kNAMRackResamplerGuardSamples, false, false, true); namOutputBuffer.setSize(2, realtimeBufferCapacity + kNAMRackResamplerGuardSamples, false, false, true); namResampledInputBuffer.setSize(2, resampledCapacity, false, false, true); @@ -18317,6 +18892,7 @@ void OpenStudioNAMRack::prepareToPlay(double sampleRate, int samplesPerBlock) octaverUpMix.load(std::memory_order_relaxed)) : 0.0f); rackPolyOctaver.prepare(cachedSampleRate, cachedBlockSize); + rackFuzz.prepare(cachedSampleRate, getFuzzParameters()); resetPrecisionDriveGateState( precisionDriveGate.load(std::memory_order_relaxed) <= 0.0001f); resetAmpFaceplateState(); @@ -18591,6 +19167,7 @@ void OpenStudioNAMRack::releaseResources() resetCompressorToneStage(false); rackPolyOctaver.setLevels(1.0f, 0.0f, 0.0f); rackPolyOctaver.reset(); + rackFuzz.reset(); rackChaos.releaseResources(); compressorWasActive = false; compressorBypassDrainSamples = 0; @@ -18935,6 +19512,9 @@ void OpenStudioNAMRack::syncEmbeddedProcessorParameters() noexcept void OpenStudioNAMRack::reset() { + invalidateEmbeddedDriveCoefficientCaches(); + diagnosticPrecisionDriveCoefficientDesignCount.store(0, std::memory_order_relaxed); + diagnosticChaosCoefficientDesignCount.store(0, std::memory_order_relaxed); publishedTempoBpm.store(0.0f, std::memory_order_relaxed); publishedDelayTailSeconds.store(0.0f, std::memory_order_relaxed); inputLevelDb.store(-90.0f, std::memory_order_relaxed); @@ -19307,6 +19887,7 @@ void OpenStudioNAMRack::reset() octaverUpMix.load(std::memory_order_relaxed)) : 0.0f); rackPolyOctaver.reset(); + rackFuzz.prepare(cachedSampleRate, getFuzzParameters()); const bool resetCompressorActive = compressorEnabled.load( std::memory_order_relaxed) >= 0.5f @@ -21857,6 +22438,14 @@ juce::var OpenStudioNAMRack::getDiagnosticState() const "embeddedDriveMaximumProcessMs", diagnosticEmbeddedDriveMaximumProcessMs.load( std::memory_order_relaxed)); + obj->setProperty( + "precisionDriveCoefficientDesignCount", + static_cast(diagnosticPrecisionDriveCoefficientDesignCount.load( + std::memory_order_relaxed))); + obj->setProperty( + "distortionCoefficientDesignCount", + static_cast(diagnosticChaosCoefficientDesignCount.load( + std::memory_order_relaxed))); obj->setProperty( "embeddedDriveSharedOversamplingLatencySamples", embeddedDriveOversamplingLatencySamples); @@ -24442,6 +25031,21 @@ std::int64_t OpenStudioNAMRack::getCachedReverbTailSamples( return reverbTailCache.samples; } +NAMFuzz::Parameters OpenStudioNAMRack::getFuzzParameters() const noexcept +{ + const float voice = fuzzVoice.load(std::memory_order_relaxed); + return { + fuzzEnabled.load(std::memory_order_relaxed) >= 0.5f, + fuzzDrive.load(std::memory_order_relaxed), + fuzzTone.load(std::memory_order_relaxed), + fuzzLevelDb.load(std::memory_order_relaxed), + std::isfinite(voice) ? juce::roundToInt(juce::jlimit(0.0f, 2.0f, voice)) : 0, + fuzzCleanMix.load(std::memory_order_relaxed), + isBassInstrumentProfileForCurrentBlock(), + calibrationReferenceDbu.load(std::memory_order_relaxed), + }; +} + void OpenStudioNAMRack::processDualOctaverStage( juce::AudioBuffer& buffer) { @@ -24463,7 +25067,32 @@ void OpenStudioNAMRack::processDualOctaverStage( enabled ? requestedDirectMix : 1.0f, enabled ? requestedDownMix : 0.0f, enabled ? requestedUpMix : 0.0f); - rackPolyOctaver.processBlock(buffer); + rackFuzz.setParameters(getFuzzParameters()); + // Hosts can exceed the prepared callback size. Reuse the fixed-capacity + // mono envelope and process the complete octave/fuzz pair in zero-copy + // views, so every frame still receives its actual smoothed mix gain. + const bool hasExcitationStorage = + octaverExcitationGainBuffer.getNumChannels() > 0 + && octaverExcitationGainBuffer.getNumSamples() > 0; + float fallbackExcitationGain = 1.0f; + float* const excitationGains = hasExcitationStorage + ? octaverExcitationGainBuffer.getWritePointer(0) + : &fallbackExcitationGain; + const int capacity = hasExcitationStorage + ? octaverExcitationGainBuffer.getNumSamples() : 1; + const int numChannels = buffer.getNumChannels(); + // Supported NAM Rack layouts are mono/stereo. JUCE's external-data views + // retain their pointers inline and do not allocate for these layouts. Keep + // all presented channels so the octaver's extra-channel direct policy stays + // intact if a host supplies more destinations than the negotiated layout. + for (int start = 0; numChannels > 0 && start < buffer.getNumSamples(); start += capacity) + { + const int count = juce::jmin(capacity, buffer.getNumSamples() - start); + juce::AudioBuffer block( + buffer.getArrayOfWritePointers(), numChannels, start, count); + rackPolyOctaver.processBlock(block, excitationGains); + rackFuzz.processBlock(block, excitationGains); + } octaverWasActive = enabled && (requestedDownMix > 0.0001f || requestedUpMix > 0.0001f @@ -25359,11 +25988,21 @@ void OpenStudioNAMRack::processEmbeddedDriveIsland( return; } - smoothedEmbeddedDriveOperatingGain.setTargetValue( - getNAMEmbeddedDriveOperatingGain( - calibrationReferenceDbu.load( - std::memory_order_relaxed), - isBassInstrumentProfileForCurrentBlock())); + const float calibrationReference = calibrationReferenceDbu.load( + std::memory_order_relaxed); + const bool bassOperatingProfile = isBassInstrumentProfileForCurrentBlock(); + auto& operatingCache = embeddedDriveOperatingGainCache; + if (! operatingCache.valid + || operatingCache.control != calibrationReference + || operatingCache.bass != bassOperatingProfile) + { + operatingCache.control = calibrationReference; + operatingCache.bass = bassOperatingProfile; + operatingCache.gain = getNAMEmbeddedDriveOperatingGain( + calibrationReference, bassOperatingProfile); + operatingCache.valid = true; + } + smoothedEmbeddedDriveOperatingGain.setTargetValue(operatingCache.gain); auto* const operatingGainEnvelope = embeddedDriveOperatingGainBuffer .getWritePointer(0); @@ -25548,35 +26187,57 @@ void OpenStudioNAMRack::processPrecisionDriveStage(juce::AudioBuffer& buf const float embeddedSampleRate = static_cast( juce::jmax(1.0, cachedSampleRate * static_cast(activeEmbeddedDriveOversamplingFactor.load(std::memory_order_relaxed)))); - smoothedPrecisionDriveBandGain.setTargetValue( - getNAMPrecisionDriveBandGain(drive)); - smoothedPrecisionDriveAttackCoefficient.setTargetValue( - getNAMPrecisionDriveAttackCoefficient( - attack, - embeddedSampleRate, - isBassInstrumentProfileForCurrentBlock())); - smoothedPrecisionDriveBrightCoefficient.setTargetValue( - getNAMPrecisionDriveBrightCoefficient( - bright, - embeddedSampleRate, - isBassInstrumentProfileForCurrentBlock())); - smoothedPrecisionDriveBassLowPreservation.setTargetValue( - isBassInstrumentProfileForCurrentBlock() ? 0.72f : 0.0f); + const bool bassProfile = isBassInstrumentProfileForCurrentBlock(); + auto& coefficients = precisionDriveCoefficientCache; + if (! coefficients.valid + || coefficients.sampleRate != embeddedSampleRate + || coefficients.drive != drive + || coefficients.attack != attack + || coefficients.bright != bright + || coefficients.bass != bassProfile) + { + smoothedPrecisionDriveBandGain.setTargetValue( + getNAMPrecisionDriveBandGain(drive)); + smoothedPrecisionDriveAttackCoefficient.setTargetValue( + getNAMPrecisionDriveAttackCoefficient( + attack, embeddedSampleRate, bassProfile)); + smoothedPrecisionDriveBrightCoefficient.setTargetValue( + getNAMPrecisionDriveBrightCoefficient( + bright, embeddedSampleRate, bassProfile)); + smoothedPrecisionDriveBassLowPreservation.setTargetValue( + bassProfile ? 0.72f : 0.0f); + // The retired OD808 circuit remains available to internal historical + // regression fixtures. Keep its target ramps warm without redesigning + // its tone network on every unchanged public Precision callback. + smoothedScreamerDriveResistanceOhms.setTargetValue( + getNAMScreamerDriveResistanceOhms(drive)); + smoothedScreamerFeedbackLegCapacitanceFarads.setTargetValue( + getNAMScreamerFeedbackLegCapacitanceFarads(attack, bassProfile)); + const auto targetScreamerTone = + getNAMScreamerToneCoefficients(bright, embeddedSampleRate); + smoothedScreamerToneB0.setTargetValue(targetScreamerTone.b0); + smoothedScreamerToneB1.setTargetValue(targetScreamerTone.b1); + smoothedScreamerToneB2.setTargetValue(targetScreamerTone.b2); + smoothedScreamerToneA1.setTargetValue(targetScreamerTone.a1); + smoothedScreamerToneA2.setTargetValue(targetScreamerTone.a2); + coefficients.dcCoefficient = std::exp( + -juce::MathConstants::twoPi + * namNativeDriveDcBlockerHz / embeddedSampleRate); + coefficients.screamerInputHighPass1 = + getNAMScreamerHighPassCoefficient(15.9f, embeddedSampleRate); + coefficients.screamerInputHighPass2 = + getNAMScreamerHighPassCoefficient(15.6f, embeddedSampleRate); + coefficients.screamerOutputHighPass = + getNAMScreamerHighPassCoefficient(1.59f, embeddedSampleRate); + coefficients.sampleRate = embeddedSampleRate; + coefficients.drive = drive; + coefficients.attack = attack; + coefficients.bright = bright; + coefficients.bass = bassProfile; + coefficients.valid = true; + diagnosticPrecisionDriveCoefficientDesignCount.fetch_add(1, std::memory_order_relaxed); + } smoothedPrecisionDriveVoice.setTargetValue(requestedVoice); - smoothedScreamerDriveResistanceOhms.setTargetValue( - getNAMScreamerDriveResistanceOhms(drive)); - smoothedScreamerFeedbackLegCapacitanceFarads.setTargetValue( - getNAMScreamerFeedbackLegCapacitanceFarads( - attack, - isBassInstrumentProfileForCurrentBlock())); - const auto targetScreamerTone = - getNAMScreamerToneCoefficients( - bright, embeddedSampleRate); - smoothedScreamerToneB0.setTargetValue(targetScreamerTone.b0); - smoothedScreamerToneB1.setTargetValue(targetScreamerTone.b1); - smoothedScreamerToneB2.setTargetValue(targetScreamerTone.b2); - smoothedScreamerToneA1.setTargetValue(targetScreamerTone.a1); - smoothedScreamerToneA2.setTargetValue(targetScreamerTone.a2); const bool renderScreamerCircuit = requestedVoice >= 0.5f || smoothedPrecisionDriveVoice.isSmoothing() @@ -25590,9 +26251,7 @@ void OpenStudioNAMRack::processPrecisionDriveStage(juce::AudioBuffer& buf // One-pole DC blocking follows the intentionally asymmetric cell. Its // state is channel-local, as are both tone-shaping sections, so silent or // unrelated stereo lanes can never cross-modulate each other. - const float dcCoefficient = std::exp( - -juce::MathConstants::twoPi - * namNativeDriveDcBlockerHz / embeddedSampleRate); + const float dcCoefficient = coefficients.dcCoefficient; constexpr float diodeBias = 0.025f; constexpr float positiveKnee = 0.44f; constexpr float negativeKnee = 0.49f; @@ -25602,14 +26261,11 @@ void OpenStudioNAMRack::processPrecisionDriveStage(juce::AudioBuffer& buf const float zeroResponse = positiveKnee * std::tanh( diodeBias / positiveKnee); const float screamerInputHighPass1Coefficient = - getNAMScreamerHighPassCoefficient( - 15.9f, embeddedSampleRate); + coefficients.screamerInputHighPass1; const float screamerInputHighPass2Coefficient = - getNAMScreamerHighPassCoefficient( - 15.6f, embeddedSampleRate); + coefficients.screamerInputHighPass2; const float screamerOutputHighPassCoefficient = - getNAMScreamerHighPassCoefficient( - 1.59f, embeddedSampleRate); + coefficients.screamerOutputHighPass; constexpr float screamerReissueOutputLoadGain = 1000000.0f / (1000000.0f + 10000.0f); const int numSamples = buffer.getNumSamples(); @@ -25782,9 +26438,13 @@ void OpenStudioNAMRack::processPrecisionDriveStage(juce::AudioBuffer& buf } } - smoothedPrecisionDriveVolumeGain.setTargetValue( - juce::Decibels::decibelsToGain( - volumeDb)); + if (! precisionDriveVolumeCache.valid || precisionDriveVolumeCache.control != volumeDb) + { + precisionDriveVolumeCache.control = volumeDb; + precisionDriveVolumeCache.gain = juce::Decibels::decibelsToGain(volumeDb); + precisionDriveVolumeCache.valid = true; + } + smoothedPrecisionDriveVolumeGain.setTargetValue(precisionDriveVolumeCache.gain); applySmoothedGain(buffer, smoothedPrecisionDriveVolumeGain); // The same linked gain is applied independently to every audio lane. if (precisionDriveGateEnvelopeActiveForBlock @@ -25929,9 +26589,6 @@ void OpenStudioNAMRack::processChaosStage(juce::AudioBuffer& buffer, juce const float weight = juce::jlimit( 0.0f, 1.0f, chaosBlockWeight); - const auto voice = getNAMChaosVoice( - chaosBlockMode, - drive); const float embeddedSampleRate = static_cast( juce::jmax( 1.0, @@ -25939,89 +26596,111 @@ void OpenStudioNAMRack::processChaosStage(juce::AudioBuffer& buffer, juce * static_cast( activeEmbeddedDriveOversamplingFactor.load( std::memory_order_relaxed)))); - const auto onePole = [embeddedSampleRate] (float hz) noexcept - { - return 1.0f - std::exp( - -juce::MathConstants::twoPi * hz + auto& coefficients = chaosCoefficientCache; + if (! coefficients.valid + || coefficients.sampleRate != embeddedSampleRate + || coefficients.mode != chaosBlockMode + || coefficients.drive != drive + || coefficients.tone != tone + || coefficients.weight != weight + || coefficients.profile != chaosBlockProfile) + { + const auto voice = getNAMChaosVoice(chaosBlockMode, drive); + const auto onePole = [embeddedSampleRate] (float hz) noexcept + { + return 1.0f - std::exp( + -juce::MathConstants::twoPi * hz + / embeddedSampleRate); + }; + const float toneCurve = std::pow(tone, 0.82f); + const bool bassProfile = + chaosBlockProfile == bassInstrumentProfile; + const float profileCutScale = bassProfile ? 0.62f : 1.0f; + const float weightCutHz = profileCutScale + * (voice.weightMinimumHz + + (1.0f - weight) * (1.0f - weight) + * (voice.weightMaximumHz + - voice.weightMinimumHz)); + + smoothedChaosPreGain.setTargetValue( + juce::Decibels::decibelsToGain(voice.preGainDb)); + smoothedChaosDensity.setTargetValue(voice.density); + smoothedChaosClippingSymmetry.setTargetValue( + voice.clippingSymmetry); + smoothedChaosPreLowCoefficient.setTargetValue( + onePole( + voice.preLowMinimumHz + + (voice.preLowMaximumHz - voice.preLowMinimumHz) + * toneCurve)); + smoothedChaosFirmBlend.setTargetValue(voice.firmBlend); + smoothedChaosCompressionBlend.setTargetValue( + voice.compressionBlend); + smoothedChaosDiodeBlend.setTargetValue(voice.diodeBlend); + smoothedChaosMakeupGain.setTargetValue( + juce::Decibels::decibelsToGain(voice.makeupGainDb)); + smoothedChaosWeightCoefficient.setTargetValue( + onePole(weightCutHz)); + smoothedChaosInterstageLowCoefficient.setTargetValue( + onePole( + voice.interstageMinimumHz + + (voice.interstageMaximumHz + - voice.interstageMinimumHz) * toneCurve)); + smoothedChaosPresenceCoefficient.setTargetValue( + onePole(voice.presenceHz)); + smoothedChaosPresenceAmount.setTargetValue( + voice.presenceAmount + (tone - 0.5f) * 0.62f); + smoothedChaosBodyLowCoefficient.setTargetValue( + onePole(voice.bodyLowHz)); + smoothedChaosBodyHighCoefficient.setTargetValue( + onePole(voice.bodyHighHz)); + smoothedChaosBodyAmount.setTargetValue(voice.bodyAmount); + smoothedChaosAttackLowCoefficient.setTargetValue( + onePole(voice.attackLowHz)); + smoothedChaosAttackHighCoefficient.setTargetValue( + onePole(voice.attackHighHz)); + smoothedChaosAttackAmount.setTargetValue(voice.attackAmount); + smoothedChaosHarshLowCoefficient.setTargetValue( + onePole(voice.harshLowHz)); + smoothedChaosHarshHighCoefficient.setTargetValue( + onePole(voice.harshHighHz)); + smoothedChaosHarshAmount.setTargetValue(voice.harshAmount); + smoothedChaosPostLowCoefficient.setTargetValue( + onePole( + voice.postLowMinimumHz + + (voice.postLowMaximumHz - voice.postLowMinimumHz) + * toneCurve)); + smoothedChaosLowPreservation.setTargetValue( + bassProfile + ? voice.bassLowPreservation + : voice.guitarLowPreservation); + + coefficients.diodeDriveDb = voice.diodeDriveDb; + coefficients.clippingSymmetry = voice.clippingSymmetry; + coefficients.dcCoefficient = std::exp( + -juce::MathConstants::twoPi * namNativeDriveDcBlockerHz / embeddedSampleRate); - }; - const float toneCurve = std::pow(tone, 0.82f); - const bool bassProfile = - chaosBlockProfile == bassInstrumentProfile; - const float profileCutScale = bassProfile ? 0.62f : 1.0f; - const float weightCutHz = profileCutScale - * (voice.weightMinimumHz - + (1.0f - weight) * (1.0f - weight) - * (voice.weightMaximumHz - - voice.weightMinimumHz)); - - smoothedChaosPreGain.setTargetValue( - juce::Decibels::decibelsToGain(voice.preGainDb)); - smoothedChaosDensity.setTargetValue(voice.density); - smoothedChaosClippingSymmetry.setTargetValue( - voice.clippingSymmetry); - smoothedChaosPreLowCoefficient.setTargetValue( - onePole( - voice.preLowMinimumHz - + (voice.preLowMaximumHz - voice.preLowMinimumHz) - * toneCurve)); - smoothedChaosFirmBlend.setTargetValue(voice.firmBlend); - smoothedChaosCompressionBlend.setTargetValue( - voice.compressionBlend); - smoothedChaosDiodeBlend.setTargetValue(voice.diodeBlend); - smoothedChaosMakeupGain.setTargetValue( - juce::Decibels::decibelsToGain(voice.makeupGainDb)); - smoothedChaosWeightCoefficient.setTargetValue( - onePole(weightCutHz)); - smoothedChaosInterstageLowCoefficient.setTargetValue( - onePole( - voice.interstageMinimumHz - + (voice.interstageMaximumHz - - voice.interstageMinimumHz) * toneCurve)); - smoothedChaosPresenceCoefficient.setTargetValue( - onePole(voice.presenceHz)); - smoothedChaosPresenceAmount.setTargetValue( - voice.presenceAmount + (tone - 0.5f) * 0.62f); - smoothedChaosBodyLowCoefficient.setTargetValue( - onePole(voice.bodyLowHz)); - smoothedChaosBodyHighCoefficient.setTargetValue( - onePole(voice.bodyHighHz)); - smoothedChaosBodyAmount.setTargetValue(voice.bodyAmount); - smoothedChaosAttackLowCoefficient.setTargetValue( - onePole(voice.attackLowHz)); - smoothedChaosAttackHighCoefficient.setTargetValue( - onePole(voice.attackHighHz)); - smoothedChaosAttackAmount.setTargetValue(voice.attackAmount); - smoothedChaosHarshLowCoefficient.setTargetValue( - onePole(voice.harshLowHz)); - smoothedChaosHarshHighCoefficient.setTargetValue( - onePole(voice.harshHighHz)); - smoothedChaosHarshAmount.setTargetValue(voice.harshAmount); - smoothedChaosPostLowCoefficient.setTargetValue( - onePole( - voice.postLowMinimumHz - + (voice.postLowMaximumHz - voice.postLowMinimumHz) - * toneCurve)); - smoothedChaosLowPreservation.setTargetValue( - bassProfile - ? voice.bassLowPreservation - : voice.guitarLowPreservation); - - rackChaos.drive.store( - voice.diodeDriveDb, std::memory_order_relaxed); - rackChaos.toneFreq.store( - 3000.0f + tone * 10500.0f, - std::memory_order_relaxed); - rackChaos.asymmetry.store( - voice.clippingSymmetry, - std::memory_order_relaxed); + coefficients.lowPreservationCoefficient = 1.0f - std::exp( + -juce::MathConstants::twoPi * 110.0f / embeddedSampleRate); + coefficients.sampleRate = embeddedSampleRate; + coefficients.mode = chaosBlockMode; + coefficients.drive = drive; + coefficients.tone = tone; + coefficients.weight = weight; + coefficients.profile = chaosBlockProfile; + coefficients.valid = true; + diagnosticChaosCoefficientDesignCount.fetch_add(1, std::memory_order_relaxed); + } + + // Re-publish the embedded processor's operating values even on a cache + // hit: an oversize/bypass fallback can have changed its Mix independently. + rackChaos.drive.store(coefficients.diodeDriveDb, std::memory_order_relaxed); + rackChaos.toneFreq.store(3000.0f + tone * 10500.0f, std::memory_order_relaxed); + rackChaos.asymmetry.store(coefficients.clippingSymmetry, std::memory_order_relaxed); rackChaos.mix.store(1.0f, std::memory_order_relaxed); const int numSamples = buffer.getNumSamples(); const int numChannels = buffer.getNumChannels(); - const float dcCoefficient = std::exp( - -juce::MathConstants::twoPi * namNativeDriveDcBlockerHz - / embeddedSampleRate); + const float dcCoefficient = coefficients.dcCoefficient; for (int sample = 0; sample < numSamples; ++sample) { const float preGain = smoothedChaosPreGain.getNextValue(); @@ -26158,9 +26837,8 @@ void OpenStudioNAMRack::processChaosStage(juce::AudioBuffer& buffer, juce { smoothedChaosWetMix.setTargetValue(wet); - const float lowPreservationCoefficient = 1.0f - std::exp( - -juce::MathConstants::twoPi - * 110.0f / embeddedSampleRate); + const float lowPreservationCoefficient = + coefficients.lowPreservationCoefficient; for (int sample = 0; sample < numSamples; ++sample) { const float currentWet = @@ -26213,13 +26891,15 @@ void OpenStudioNAMRack::processChaosStage(juce::AudioBuffer& buffer, juce } } } - smoothedChaosLevelGain.setTargetValue( - juce::Decibels::decibelsToGain( - juce::jlimit( - -12.0f, - 12.0f, - chaosLevelDb.load( - std::memory_order_relaxed)))); + const float levelDb = juce::jlimit( + -12.0f, 12.0f, chaosLevelDb.load(std::memory_order_relaxed)); + if (! chaosLevelCache.valid || chaosLevelCache.control != levelDb) + { + chaosLevelCache.control = levelDb; + chaosLevelCache.gain = juce::Decibels::decibelsToGain(levelDb); + chaosLevelCache.valid = true; + } + smoothedChaosLevelGain.setTargetValue(chaosLevelCache.gain); applySmoothedGain(buffer, smoothedChaosLevelGain); if (chaosGateEnvelopeActiveForBlock && chaosGateGainBuffer.getNumSamples() >= buffer.getNumSamples()) @@ -26410,6 +27090,14 @@ void OpenStudioNAMRack::processDelayStage(juce::AudioBuffer& buffer, juce publishedSeconds, std::memory_order_relaxed); }; const bool enabled = delayEnabled.load(std::memory_order_relaxed) >= 0.5f; + if (!enabled && delayTailSamplesRemaining <= 0) + { + // Off-state controls are resolved and primed on the next engagement. + // With no retained tail, macro trigonometry cannot affect this block. + publishedDelayTailSeconds.store(0.0f, std::memory_order_relaxed); + delayWasActive = false; + return; + } const auto requestedDelayMacro = resolveDelayMacroState( delayTimeMs.load(std::memory_order_relaxed), delayFeedback.load(std::memory_order_relaxed), @@ -28836,6 +29524,12 @@ void OpenStudioNAMRack::getStateInformation(juce::MemoryBlock& destData) tree.setProperty("octaverDownMix", static_cast(octaverDownMix.load()), nullptr); tree.setProperty("octaverUpMix", static_cast(octaverUpMix.load()), nullptr); tree.setProperty("octaverDirectMix", static_cast(octaverDirectMix.load()), nullptr); + tree.setProperty("fuzzEnabled", static_cast(fuzzEnabled.load()), nullptr); + tree.setProperty("fuzzDrive", static_cast(fuzzDrive.load()), nullptr); + tree.setProperty("fuzzTone", static_cast(fuzzTone.load()), nullptr); + tree.setProperty("fuzzLevelDb", static_cast(fuzzLevelDb.load()), nullptr); + tree.setProperty("fuzzVoice", static_cast(fuzzVoice.load()), nullptr); + tree.setProperty("fuzzCleanMix", static_cast(fuzzCleanMix.load()), nullptr); tree.setProperty("preEqEnabled", static_cast(preEqEnabled.load()), nullptr); tree.setProperty("preEq120Db", static_cast(preEq120Db.load()), nullptr); tree.setProperty("preEq250Db", static_cast(preEq250Db.load()), nullptr); @@ -29351,6 +30045,12 @@ bool OpenStudioNAMRack::restoreStateInformationInternal(const void* data, octaverDownMix = static_cast((double)tree.getProperty("octaverDownMix", 0.32)); octaverUpMix = static_cast((double)tree.getProperty("octaverUpMix", 0.18)); octaverDirectMix = static_cast((double)tree.getProperty("octaverDirectMix", 1.0)); + fuzzEnabled = static_cast(sanitizeNAMRackDelayToggle(tree.getProperty("fuzzEnabled", 0.0), false)); + fuzzDrive = static_cast(sanitizeNAMRackDelayContinuous(tree.getProperty("fuzzDrive", 0.5), 0.5, 0.0, 1.0)); + fuzzTone = static_cast(sanitizeNAMRackDelayContinuous(tree.getProperty("fuzzTone", 0.5), 0.5, 0.0, 1.0)); + fuzzLevelDb = static_cast(sanitizeNAMRackDelayContinuous(tree.getProperty("fuzzLevelDb", 0.0), 0.0, -18.0, 12.0)); + fuzzVoice = static_cast(std::round(sanitizeNAMRackDelayContinuous(tree.getProperty("fuzzVoice", 0.0), 0.0, 0.0, 2.0))); + fuzzCleanMix = static_cast(sanitizeNAMRackDelayContinuous(tree.getProperty("fuzzCleanMix", 0.0), 0.0, 0.0, 1.0)); preEqEnabled = static_cast(sanitizeNAMRackDelayToggle( tree.getProperty("preEqEnabled", 0.0), false)); preEq120Db = static_cast(sanitizeNAMRackDelayContinuous( @@ -29832,6 +30532,9 @@ OpenStudioBasicSynthInstrument::OpenStudioBasicSynthInstrument() .withInput("Input", juce::AudioChannelSet::stereo(), true) .withOutput("Output", juce::AudioChannelSet::stereo(), true)) { + for (size_t note = 0; note < noteFrequencies.size(); ++note) + noteFrequencies[note] = static_cast(juce::MidiMessage::getMidiNoteInHertz(static_cast(note))); + cachedPanLeft.fill(1); cachedPanRight.fill(1); } float OpenStudioBasicSynthInstrument::releaseModulationBound() const noexcept @@ -29880,6 +30583,7 @@ double OpenStudioBasicSynthInstrument::getMaximumTailLengthSeconds() const noexc void OpenStudioBasicSynthInstrument::prepareToPlay(double sampleRate, int samplesPerBlock) { + ratePole.prepare(sampleRate); juce::ignoreUnused(samplesPerBlock); cachedSampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; tailSampleRate.store(cachedSampleRate, std::memory_order_relaxed); @@ -30058,7 +30762,9 @@ void OpenStudioBasicSynthInstrument::processBlock(juce::AudioBuffer& buff const float sr = static_cast(juce::jmax(1.0, cachedSampleRate)); const float attackStep = 1.0f / juce::jmax(1.0f, sr * attackMs.load(std::memory_order_relaxed) * 0.001f); const float sustainLevel = juce::jlimit(0.0f, 1.0f, sustain.load()); - const float decayStep = (1.0f - sustainLevel) / juce::jmax(1.0f, sr * decayMs.load() * .001f); + const float decayMilliseconds = decayMs.load(std::memory_order_relaxed); + const float decaySamples = sr * decayMilliseconds * .001f; + const float decayStep = (1.0f - sustainLevel) / juce::jmax(1.0f, decaySamples); const float blend = juce::jlimit(0.0f, 1.0f, oscillatorBlend.load()); const float releaseStep = 1.0f / juce::jmax(1.0f, sr * releaseMs.load(std::memory_order_relaxed) * 0.001f); const float bright = juce::jlimit(0.0f, 1.0f, brightness.load(std::memory_order_relaxed)); @@ -30098,8 +30804,21 @@ void OpenStudioBasicSynthInstrument::processBlock(juce::AudioBuffer& buff const float globalLfo=modulation.lfo(globalModulationVoice,modulationFrame); const float independentBlend = filterEnvelopeBlend.getNextValue(); float mixed = 0.0f,mixedL=0.0f,mixedR=0.0f; - std::array channelLevels{},panL{},panR{}; - for(size_t ch=0;ch<16;++ch){auto control=channelFrame[ch];const int manager=mpe.manager(ch);if(manager>=0){const auto& master=channelFrame[static_cast(manager)];control.level*=master.level;control.pan=BuiltInMIDIChannelMix::biasPan(control.pan,master.pan);}channelLevels[ch]=control.level;panL[ch]=control.pan==0?1:std::sqrt(1-control.pan);panR[ch]=control.pan==0?1:std::sqrt(1+control.pan);} + std::array channelLevels{}; + for(size_t ch=0;ch<16;++ch) + { + auto control=channelFrame[ch];const int manager=mpe.manager(ch); + if(manager>=0){const auto& master=channelFrame[static_cast(manager)];control.level*=master.level;control.pan=BuiltInMIDIChannelMix::biasPan(control.pan,master.pan);} + channelLevels[ch]=control.level; + if(control.pan!=cachedPan[ch]) + { + cachedPan[ch]=control.pan; + cachedPanLeft[ch]=control.pan==0?1:std::sqrt(1-control.pan); + cachedPanRight[ch]=control.pan==0?1:std::sqrt(1+control.pan); + } + } + const auto& panL=cachedPanLeft; + const auto& panR=cachedPanRight; for (int voiceIndex = 0; voiceIndex < voiceCount;) { const auto voiceRef = voiceRefs[static_cast(voiceIndex)]; @@ -30114,10 +30833,11 @@ void OpenStudioBasicSynthInstrument::processBlock(juce::AudioBuffer& buff } const auto& previous=previousRoutes[channel][slot]; - const float voiceAttackStep=previous[10]==0?attackStep:attackStep*std::exp2(-4*previous[10]); + auto& work=voiceWorkCache[channel][slot]; + const float voiceAttackStep=previous[10]==0?attackStep:attackStep*work.attack.exponentialOfTwo(-4*previous[10]); const float voiceSustain=previous[12]==0?sustainLevel:juce::jlimit(0.0f,1.0f,sustainLevel+previous[12]); - const float voiceDecayStep=previous[11]==0&&previous[12]==0?decayStep:(1-voiceSustain)/juce::jmax(1.0f,sr*decayMs.load()*.001f*std::exp2(4*previous[11])); - const float voiceReleaseStep=previous[13]==0?releaseStep:releaseStep*std::exp2(-4*previous[13]); + const float voiceDecayStep=previous[11]==0&&previous[12]==0?decayStep:(1-voiceSustain)/juce::jmax(1.0f,decaySamples*work.decay.exponentialOfTwo(4*previous[11])); + const float voiceReleaseStep=previous[13]==0?releaseStep:releaseStep*work.release.exponentialOfTwo(-4*previous[13]); if (active[channel][slot] && !releasing[channel][slot]) { if (!decaying[channel][slot]) @@ -30142,10 +30862,10 @@ void OpenStudioBasicSynthInstrument::processBlock(juce::AudioBuffer& buff const float independentEnvelope = filterEnvelopes[channel][slot].next(releasing[channel][slot],previous[14],previous[15],previous[16],previous[17]); const float filterEnv = independentBlend <= 0 ? envelope[channel][slot] : envelope[channel][slot] + independentBlend * (independentEnvelope - envelope[channel][slot]); - const float freq = static_cast(juce::MidiMessage::getMidiNoteInHertz(static_cast(note))); + const float freq = noteFrequencies[note]; const bool expressiveMember = mpe.manager(channel) >= 0; const auto expression = expressiveMember ? mpe.next(channel, slot) : BuiltInSynthMPE::Expression {}; - const float bendFactor = std::pow(2.0f, (expressiveMember ? expression.bend : pitchBendSemitones[channel]) / 12.0f); + const float bendFactor = work.bend.powerOfTwo((expressiveMember ? expression.bend : pitchBendSemitones[channel]) / 12.0f); const float mod = juce::jlimit(0.0f, 1.0f, expressiveMember ? expression.wheel : modWheel[channel])*matrixFrame.legacyWheel; const float vibrato = std::sin(twoPi * (static_cast(ageSamples[channel][slot]) / sr) * 5.4f) * mod * 0.018f; auto& modulationVoice = modulationVoices[channel][slot]; @@ -30181,7 +30901,7 @@ void OpenStudioBasicSynthInstrument::processBlock(juce::AudioBuffer& buff float voice = saw * (1.16f * (1.0f - voiceBlend)) + square * (2.0f * voiceBlend) * (0.22f + (voiceBrightness + mod * 0.25f) * 0.18f) + subOsc * voiceSub + transient; const float voiceFilterCoeff=routed[9]==0?filterCoeff:juce::jlimit(.015f,.55f,.04f+voiceBrightness*voiceBrightness*.46f); - filterState[channel][slot] += voiceFilterCoeff * (voice - filterState[channel][slot]); + filterState[channel][slot] += (signalPath.load(std::memory_order_relaxed) >= .5f ? ratePole.map(voiceFilterCoeff) : voiceFilterCoeff) * (voice - filterState[channel][slot]); auto voiceModulation=modulationFrame;voiceModulation.depth=voiceDepth; if(routed[18]!=0)voiceModulation.envelope=juce::jlimit(-8.0f,8.0f,modulationFrame.envelope+4*routed[18]); if(routed[19]!=0)voiceModulation.keyTrack=juce::jlimit(0.0f,1.0f,modulationFrame.keyTrack+routed[19]); @@ -30229,6 +30949,7 @@ void OpenStudioBasicSynthInstrument::processBlock(juce::AudioBuffer& buff void OpenStudioBasicSynthInstrument::getStateInformation(juce::MemoryBlock& destData) { saveParamsToMemory(destData, "OpenStudioBasicSynthInstrument", { + { "signalPath", signalPath.load() }, { "attackMs", attackMs.load() }, { "decayMs", decayMs.load() }, { "sustain", sustain.load() }, { "oscillatorBlend", oscillatorBlend.load() }, { "releaseMs", releaseMs.load() }, @@ -30294,9 +31015,11 @@ void OpenStudioBasicSynthInstrument::getStateInformation(juce::MemoryBlock& dest void OpenStudioBasicSynthInstrument::setStateInformation(const void* data, int sizeInBytes) { + const float previousSignalPath = signalPath.load(); auto tree = loadParamsFromMemory(data, sizeInBytes, "OpenStudioBasicSynthInstrument"); if (!tree.isValid()) return; + signalPath.store(static_cast(tree.getProperty("signalPath", 0.0f)) >= .5f ? 1.0f : 0.0f); decayMs = juce::jlimit(1.0f, 5000.0f, static_cast(tree.getProperty("decayMs", 250.0f))); sustain = juce::jlimit(0.0f, 1.0f, static_cast(tree.getProperty("sustain", 1.0f))); @@ -30318,6 +31041,9 @@ void OpenStudioBasicSynthInstrument::setStateInformation(const void* data, int s for(const auto& control:macroMappings){const float raw=static_cast(tree.getProperty(control.id,control.initial));setModulationControl(control.id,std::isfinite(raw)?raw:control.initial);} ccMacros.reset(); channelMix.reset(cachedSampleRate); + + if (previousSignalPath != signalPath.load() && getSampleRate() > 0 && getBlockSize() > 0) + prepareToPlay(getSampleRate(), getBlockSize()); } bool OpenStudioBasicSynthInstrument::isBusesLayoutSupported(const BusesLayout& layouts) const @@ -30402,6 +31128,7 @@ void OpenStudioPianoInstrument::clearVoices() for (auto& notes : releasing) notes.fill(false); for (auto& notes : sustained) notes.fill(false); for (auto& notes : phase) notes.fill(0.0f); + for (auto& channel : fractionalPhase) for (auto& voice : channel) voice.fill(0.0f); for (auto& notes : velocity) notes.fill(0.0f); for (auto& notes : envelope) notes.fill(0.0f); for (auto& notes : ageSamples) notes.fill(0); @@ -30454,6 +31181,7 @@ void OpenStudioPianoInstrument::handleMidi(const juce::MidiMessage& message) releasing[static_cast(channel)][slot] = false; sustained[static_cast(channel)][slot] = false; phase[static_cast(channel)][slot] = 0.0f; + fractionalPhase[static_cast(channel)][slot].fill(0.0f); velocity[static_cast(channel)][slot] = message.getFloatVelocity(); const size_t ch = static_cast(channel); // Performance selection and strike colour are latched at note-on. @@ -30607,16 +31335,17 @@ void OpenStudioPianoInstrument::processBlock(juce::AudioBuffer& buffer, j naturalDecay[channel][slot] += 1.0 / (sr * (0.85f + bodyValue * 2.6f + (1.0f - noteBright) * .4f) * (1 + resonanceValue * pedalValues[channel] * .8f)); } const float p = phase[channel][slot]; + const auto partialRadians = [&](int index, float ratio) { return signalPath.load(std::memory_order_relaxed) >= .5f ? twoPi * builtInFractionalPhase(fractionalPhase[channel][slot][static_cast(index)], freq * ratio / sr) : twoPi * p * ratio; }; float strike = builtinNoise(ageSamples[channel][slot], static_cast(note)) * std::exp(-ageSec / 0.012f) * (0.012f + hammerValue * 0.045f) * modelTone; const float fundamental = std::sin(twoPi * p) * (0.82f + bodyValue * 0.28f); - float partial2 = std::sin(twoPi * p * 2.003f) * (0.24f + toneValue * 0.20f * modelTone) + float partial2 = std::sin(partialRadians(0, 2.003f)) * (0.24f + toneValue * 0.20f * modelTone) * std::exp(-ageSec / 1.1f) * nyquistFade(freq * 2.003f, sr); - float partial3 = std::sin(twoPi * p * 3.011f) * (0.13f + toneValue * 0.15f * modelTone) + float partial3 = std::sin(partialRadians(1, 3.011f)) * (0.13f + toneValue * 0.15f * modelTone) * std::exp(-ageSec / 0.74f) * nyquistFade(freq * 3.011f, sr); - float partial5 = std::sin(twoPi * p * 5.031f) * (0.04f + toneValue * 0.08f * modelTone) + float partial5 = std::sin(partialRadians(2, 5.031f)) * (0.04f + toneValue * 0.08f * modelTone) * std::exp(-ageSec / 0.42f) * nyquistFade(freq * 5.031f, sr); - const float soundboard = std::sin(twoPi * p * 1.497f + 0.7f) * resonanceValue * 0.09f + const float soundboard = std::sin(partialRadians(3, 1.497f) + 0.7f) * resonanceValue * 0.09f * std::exp(-ageSec / 3.8f) * nyquistFade(freq * 1.497f, sr); if (expressive) { @@ -30685,6 +31414,7 @@ void OpenStudioPianoInstrument::processBlock(juce::AudioBuffer& buffer, j void OpenStudioPianoInstrument::getStateInformation(juce::MemoryBlock& destData) { saveParamsToMemory(destData, "OpenStudioPianoInstrument", { + { "signalPath", signalPath.load() }, {"coupledBody",coupledBody.load()},{"bodyCoupling",bodyCoupling.load()},{"bodyDecay",bodyDecay.load()}, { "tone", tone.load() }, { "body", body.load() }, @@ -30705,9 +31435,11 @@ void OpenStudioPianoInstrument::getStateInformation(juce::MemoryBlock& destData) void OpenStudioPianoInstrument::setStateInformation(const void* data, int sizeInBytes) { + const float previousSignalPath = signalPath.load(); auto tree = loadParamsFromMemory(data, sizeInBytes, "OpenStudioPianoInstrument"); if (!tree.isValid()) return; + signalPath.store(static_cast(tree.getProperty("signalPath", 0.0f)) >= .5f ? 1.0f : 0.0f); for (const auto& control : coupledBodyControls) { const float value=static_cast(tree.getProperty(control.id,control.initial)); setPerformanceControl(control.id,std::isfinite(value)?value:control.initial); } for (const auto& control : performanceControls) { @@ -30724,6 +31456,9 @@ void OpenStudioPianoInstrument::setStateInformation(const void* data, int sizeIn stereoWidth = static_cast((double)tree.getProperty("stereoWidth", 0.62)); model = static_cast((double)tree.getProperty("model", 0.0)); channelMix.reset(cachedSampleRate); + + if (previousSignalPath != signalPath.load() && getSampleRate() > 0 && getBlockSize() > 0) + prepareToPlay(getSampleRate(), getBlockSize()); } bool OpenStudioPianoInstrument::isBusesLayoutSupported(const BusesLayout& layouts) const @@ -30783,6 +31518,7 @@ double OpenStudioCleanGuitarInstrument::getMaximumTailLengthSeconds() const noex void OpenStudioCleanGuitarInstrument::prepareToPlay(double sampleRate, int samplesPerBlock) { + ratePole.prepare(sampleRate); juce::ignoreUnused(samplesPerBlock); cachedSampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; tailSampleRate.store(cachedSampleRate, std::memory_order_relaxed); @@ -30813,6 +31549,7 @@ void OpenStudioCleanGuitarInstrument::clearVoices() for (auto& notes : active) notes.fill(false); for (auto& notes : releasing) notes.fill(false); for (auto& notes : phase) notes.fill(0.0f); + for (auto& channel : fractionalPhase) for (auto& voice : channel) voice.fill(0.0f); for (auto& notes : velocity) notes.fill(0.0f); for (auto& notes : envelope) notes.fill(0.0f); for (auto& notes : pluckFilter) notes.fill(0.0f); @@ -30948,6 +31685,7 @@ void OpenStudioCleanGuitarInstrument::handleMidi(const juce::MidiMessage& messag active[static_cast(channel)][slot] = true; releasing[static_cast(channel)][slot] = false; phase[static_cast(channel)][slot] = 0.0f; + fractionalPhase[static_cast(channel)][slot].fill(0.0f); velocity[static_cast(channel)][slot] = message.getFloatVelocity(); envelope[static_cast(channel)][slot] = 0.0f; pluckFilter[static_cast(channel)][slot] = 0.0f; @@ -30964,7 +31702,7 @@ void OpenStudioCleanGuitarInstrument::handleMidi(const juce::MidiMessage& messag if(loopVoice[ch][old]&&active[ch][old]&&voicePool.held[ch][old]) { transferred=pluckedLoop.transfer(ch*16+slot,ch*16+old); - if(transferred){pitch.setCurrentAndTargetValue(articulationPitch[ch][old].getCurrentValue());pitch.setTargetValue(static_cast(note));envelope[ch][slot]=envelope[ch][old];phase[ch][slot]=phase[ch][old];pluckFilter[ch][slot]=pluckFilter[ch][old];ageSamples[ch][slot]=ageSamples[ch][old];active[ch][old]=false;envelope[ch][old]=0;} + if(transferred){pitch.setCurrentAndTargetValue(articulationPitch[ch][old].getCurrentValue());pitch.setTargetValue(static_cast(note));envelope[ch][slot]=envelope[ch][old];phase[ch][slot]=phase[ch][old];fractionalPhase[ch][slot]=fractionalPhase[ch][old];pluckFilter[ch][slot]=pluckFilter[ch][old];ageSamples[ch][slot]=ageSamples[ch][old];active[ch][old]=false;envelope[ch][old]=0;} } } if(style==5){pitch.setCurrentAndTargetValue(static_cast(note)-2);pitch.setTargetValue(static_cast(note));} @@ -31099,6 +31837,7 @@ void OpenStudioCleanGuitarInstrument::processBlock(juce::AudioBuffer& buf const float ageSec = static_cast(ageSamples[channel][slot]) / sr; const float freq = baseFreq * bendFactor; const float p = phase[channel][slot]; + const auto partial = [&](int index, float ratio) { return signalPath.load(std::memory_order_relaxed) >= .5f ? builtInFractionalPhase(fractionalPhase[channel][slot][static_cast(index)], freq * ratio / sr) : p * ratio; }; const float noteBright = juce::jlimit(0.55f, 1.45f, 0.78f + (static_cast(note) - 52.0f) * 0.009f); const float pickupBright = modelIndex == 0 ? 1.08f : (modelIndex == 1 ? 1.22f : (modelIndex == 3 ? 1.18f : 0.96f)); float raw = 0; @@ -31116,15 +31855,15 @@ void OpenStudioCleanGuitarInstrument::processBlock(juce::AudioBuffer& buf const float decay = std::exp(-ageSec / bodyDecay); const float pickupPhase = modelIndex == 1 ? 0.18f : 0.09f; const float fundamental = std::sin(twoPi * p) * 0.72f; - const float partial2 = std::sin(twoPi * (p * 2.01f + pickupPhase)) * (0.28f + toneValue * 0.24f) * nyquistFade(freq * 2.01f, sr); - const float partial3 = std::sin(twoPi * (p * 3.02f + 0.31f)) * (0.16f + toneValue * 0.14f) * nyquistFade(freq * 3.02f, sr); - const float partial5 = std::sin(twoPi * (p * 5.07f + 0.11f)) * (0.045f + toneValue * 0.08f) * nyquistFade(freq * 5.07f, sr); + const float partial2 = std::sin(twoPi * (partial(0, 2.01f) + pickupPhase)) * (0.28f + toneValue * 0.24f) * nyquistFade(freq * 2.01f, sr); + const float partial3 = std::sin(twoPi * (partial(1, 3.02f) + 0.31f)) * (0.16f + toneValue * 0.14f) * nyquistFade(freq * 3.02f, sr); + const float partial5 = std::sin(twoPi * (partial(2, 5.07f) + 0.11f)) * (0.045f + toneValue * 0.08f) * nyquistFade(freq * 5.07f, sr); const float pluck = builtinNoise(ageSamples[channel][slot], static_cast(note) + 37) * std::exp(-ageSec / 0.018f) * (0.02f + pickValue * 0.075f) * pickupBright; raw = (fundamental + partial2 + partial3 + partial5 + pluck) * decay * pickupBright; } const float filterCoeff = juce::jlimit(0.04f, 0.72f, 0.12f + toneValue * 0.46f + noteBright * 0.08f); - pluckFilter[channel][slot] += filterCoeff * (raw - pluckFilter[channel][slot]); + pluckFilter[channel][slot] += (signalPath.load(std::memory_order_relaxed) >= .5f ? ratePole.map(filterCoeff) : filterCoeff) * (raw - pluckFilter[channel][slot]); raw = pluckFilter[channel][slot]; const float cleanAmp = modelIndex >= 2 ? std::tanh(raw * (1.08f + bodyValue * 0.24f)) : raw; @@ -31188,6 +31927,7 @@ void OpenStudioCleanGuitarInstrument::processBlock(juce::AudioBuffer& buf void OpenStudioCleanGuitarInstrument::getStateInformation(juce::MemoryBlock& destData) { saveParamsToMemory(destData, "OpenStudioCleanGuitarInstrument", { + { "signalPath", signalPath.load() }, {"coupledBody",coupledBody.load()},{"bodyCoupling",bodyCoupling.load()},{"bodyDecay",bodyDecay.load()}, { "model", model.load() }, { "tone", tone.load() }, @@ -31214,9 +31954,11 @@ void OpenStudioCleanGuitarInstrument::getStateInformation(juce::MemoryBlock& des void OpenStudioCleanGuitarInstrument::setStateInformation(const void* data, int sizeInBytes) { + const float previousSignalPath = signalPath.load(); auto tree = loadParamsFromMemory(data, sizeInBytes, "OpenStudioCleanGuitarInstrument"); if (!tree.isValid()) return; + signalPath.store(static_cast(tree.getProperty("signalPath", 0.0f)) >= .5f ? 1.0f : 0.0f); for (const auto& control : coupledBodyControls) { const float value=static_cast(tree.getProperty(control.id,control.initial)); setStringControl(control.id,std::isfinite(value)?value:control.initial); } for (const auto& control : stringControls) { @@ -31237,6 +31979,9 @@ void OpenStudioCleanGuitarInstrument::setStateInformation(const void* data, int outputGain = static_cast((double)tree.getProperty("outputGain", -14.0)); channelMix.reset(cachedSampleRate); publishGuitarPerformance(); + + if (previousSignalPath != signalPath.load() && getSampleRate() > 0 && getBlockSize() > 0) + prepareToPlay(getSampleRate(), getBlockSize()); } bool OpenStudioCleanGuitarInstrument::isBusesLayoutSupported(const BusesLayout& layouts) const @@ -31311,6 +32056,7 @@ void OpenStudioDrumInstrument::clearVoices() for (auto& channel : chokeGain) channel.fill(1.0f); for (auto& notes : active) notes.fill(false); for (auto& notes : phase) notes.fill(0.0f); + for (auto& channel : fractionalPhase) for (auto& voice : channel) voice.fill(0.0f); for (auto& notes : velocity) notes.fill(0.0f); for (auto& notes : ageSamples) notes.fill(0); } @@ -31401,6 +32147,7 @@ void OpenStudioDrumInstrument::handleMidi(const juce::MidiMessage& message) active[static_cast(channel)][slot] = true; voiceArticulations[static_cast(channel)][slot]=articulation;inputKeys[static_cast(channel)][slot]=message.getNoteNumber();partialPhases[static_cast(channel)][slot].fill(0); phase[static_cast(channel)][slot] = 0.0f; + fractionalPhase[static_cast(channel)][slot].fill(0.0f); velocity[static_cast(channel)][slot] = curvedVelocity; ageSamples[static_cast(channel)][slot] = 0; @@ -31414,12 +32161,11 @@ void OpenStudioDrumInstrument::handleMidi(const juce::MidiMessage& message) else if (message.isAllNotesOff() || message.isAllSoundOff()) { voicePool.releaseChannel(static_cast(channel)); + // CC123 releases notes, just like ordinary one-shot drum note-off. + // The offline renderer sends it at the source boundary to drain tails. + // Only CC120 is a channel-local hard panic. if (message.isAllSoundOff()) - { active[static_cast(channel)].fill(false); - } - - active[static_cast(channel)].fill(false); } } @@ -31519,6 +32265,8 @@ void OpenStudioDrumInstrument::processBlock(juce::AudioBuffer& buffer, ju if (phase[channel][slot] >= 1.0f) phase[channel][slot] -= std::floor(phase[channel][slot]); + const auto partialRadians = [&](int index, float ratio) { return signalPath.load(std::memory_order_relaxed) >= .5f ? twoPi * builtInFractionalPhase(fractionalPhase[channel][slot][static_cast(index)], freq * ratio / static_cast(cachedSampleRate)) : twoPi * phase[channel][slot] * ratio; }; + const auto partialGain = [&](float ratio) { return signalPath.load(std::memory_order_relaxed) >= .5f ? nyquistFade(freq * ratio, static_cast(cachedSampleRate)) : 1.0f; }; float drum = 0.0f; if(articulated)drum=BuiltInDrumArticulations::render(articulation,partialPhases[channel][slot],freq,static_cast(cachedSampleRate),ageSec/(.82f+velocityValue*.42f),noise,velocityValue,hihatPedal[channel],punchValue); else if (midiNote == 35 || midiNote == 36) @@ -31535,15 +32283,15 @@ void OpenStudioDrumInstrument::processBlock(juce::AudioBuffer& buffer, ju } else if (midiNote == 42 || midiNote == 44 || midiNote == 46 || midiNote == 22 || midiNote == 26) { - const float metal = std::sin(twoPi * phase[channel][slot] * 7.1f) * 0.24f - + std::sin(twoPi * phase[channel][slot] * 11.7f) * 0.18f; + const float metal = std::sin(partialRadians(0, 7.1f)) * 0.24f * partialGain(7.1f) + + std::sin(partialRadians(1, 11.7f)) * 0.18f * partialGain(11.7f); drum = (noise * 0.78f + metal) * env; } else if (midiNote == 49 || midiNote == 51 || midiNote == 52 || midiNote == 53 || midiNote == 55 || midiNote == 57 || midiNote == 59) { - const float shimmer = std::sin(twoPi * phase[channel][slot] * 5.3f) * 0.15f - + std::sin(twoPi * phase[channel][slot] * 9.7f) * 0.12f; + const float shimmer = std::sin(partialRadians(0, 5.3f)) * 0.15f * partialGain(5.3f) + + std::sin(partialRadians(1, 9.7f)) * 0.12f * partialGain(9.7f); drum = (noise * 0.64f + shimmer) * env; } else @@ -31552,7 +32300,7 @@ void OpenStudioDrumInstrument::processBlock(juce::AudioBuffer& buffer, ju drum = body * 0.9f + noise * 0.08f * std::exp(-ageSec / 0.04f); } - const float roomTail = std::sin(twoPi * phase[channel][slot] * 0.37f + static_cast(midiNote)) + const float roomTail = std::sin(partialRadians(2, 0.37f) + static_cast(midiNote)) * room * 0.08f * std::exp(-ageSec / 0.9f); const float voice = (drum + roomTail) * velocityValue * gain * 0.85f * juce::Decibels::decibelsToGain(juce::jlimit(-60.0f, 12.0f, pieceGain[static_cast(pieceForNote(midiNote))].load())); const float pan = juce::jlimit(-1.0f, 1.0f, drumPanPosition(midiNote) + panValues[piece]) * widthValue; @@ -31612,6 +32360,7 @@ void OpenStudioDrumInstrument::processBlock(juce::AudioBuffer& buffer, ju void OpenStudioDrumInstrument::getStateInformation(juce::MemoryBlock& destData) { saveParamsToMemory(destData, "OpenStudioDrumInstrument", { + { "signalPath", signalPath.load() }, { "kit", kit.load() }, { "tuning", tuning.load() }, { "ambience", ambience.load() }, @@ -31638,9 +32387,11 @@ void OpenStudioDrumInstrument::getStateInformation(juce::MemoryBlock& destData) void OpenStudioDrumInstrument::setStateInformation(const void* data, int sizeInBytes) { + const float previousSignalPath = signalPath.load(); auto tree = loadParamsFromMemory(data, sizeInBytes, "OpenStudioDrumInstrument"); if (!tree.isValid()) return; + signalPath.store(static_cast(tree.getProperty("signalPath", 0.0f)) >= .5f ? 1.0f : 0.0f); for (size_t i = 0; i < pieceGain.size(); ++i) pieceGain[i].store(juce::jlimit(-60.0f, 12.0f, static_cast(tree.getProperty("pieceGain" + juce::String(static_cast(i)), 0.0f)))); for (size_t i = 0; i < 8; ++i) @@ -31668,6 +32419,9 @@ void OpenStudioDrumInstrument::setStateInformation(const void* data, int sizeInB for(size_t i=0;i(tree.getProperty("noteMap"+juce::String(static_cast(i)),static_cast(i)));noteMap[i].store(std::isfinite(mapped)?static_cast(juce::jlimit(-1,127,juce::roundToInt(mapped))):static_cast(i));} mapPreset.store(std::isfinite(mapPreset.load())?juce::jlimit(0.0f,2.0f,std::round(mapPreset.load())):0.0f); channelMix.reset(cachedSampleRate); + + if (previousSignalPath != signalPath.load() && getSampleRate() > 0 && getBlockSize() > 0) + prepareToPlay(getSampleRate(), getBlockSize()); } bool OpenStudioDrumInstrument::isBusesLayoutSupported(const BusesLayout& layouts) const diff --git a/Source/BuiltInEffects2.h b/Source/BuiltInEffects2.h index 87fd2ab..3b2d7c5 100644 --- a/Source/BuiltInEffects2.h +++ b/Source/BuiltInEffects2.h @@ -1,4 +1,5 @@ #pragma once +#include "BuiltInRatePole.h" #include "BuiltInDrumArticulations.h" #include "BuiltInSpringReverb.h" #include "BuiltInSaturationColour.h" @@ -22,6 +23,7 @@ #include "BuiltInEffects.h" #include "NAMCabPresentation.h" #include "NAMPolyOctaver.h" +#include "NAMFuzz.h" #if defined(_MSC_VER) #pragma warning(push) #pragma warning(disable: 4244 4267 4305 4456) @@ -376,6 +378,12 @@ class OpenStudioReverb : public juce::AudioProcessor std::atomic damping { 0.5f }; // 0-1 std::atomic wetLevel { 0.33f }; // 0-1 std::atomic dryLevel { 0.7f }; // 0-1 + std::atomic outputTrimDb { 0.0f }; // Standalone final output, -60 to +12 dB. + std::atomic reverbCharacterMode { 0.0f }; // Original=0, opt-in Refined=1; standalone only. + std::atomic reverbReturnMode { 0.0f }; // Constructor selects Calibrated for new standalone; old missing state is Original. + std::atomic shimmerScheduling { 0.0f }; // Original=0, opt-in Spread=1; adds one pitch hop, never dry latency. + std::atomic reverbControlMode { 0.0f }; // Standalone Corrected=1; missing old state retains Legacy=0. + std::atomic ambBody { 0.0f }; // Vowel-only broadband wet readout; zero preserves the formant-only response. std::atomic width { 1.0f }; // 0-1 std::atomic freezeMode { 0.0f }; // 0 = off, 1 = on std::atomic preDelay { 0.0f }; // 0-500 ms @@ -503,7 +511,15 @@ class OpenStudioReverb : public juce::AudioProcessor void prepareToPlay(double sampleRate, int samplesPerBlock) override; void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; void releaseResources() override; - void reset() override { resetTailState(); } + void reset() override + { + resetTailState(); + // The app's explicit prepare/reset routes run outside processBlock. + // Restore Spread's deterministic primed phase for export/replay parity; + // callback runaway recovery still uses resetTailState's lazy history. + if (standaloneBanking && shimmerScheduling.load(std::memory_order_relaxed) >= .5f) + additionalSpaces.resetShimmerSchedulingHistory(); + } const juce::String getName() const override { return "OpenStudio Reverb"; } bool hasEditor() const override { return true; } @@ -634,6 +650,29 @@ class OpenStudioReverb : public juce::AudioProcessor void resetTailState() noexcept; private: + friend struct OpenStudioReverbCompatibilityRegressionAccess; + struct CompatibilityRouting + { + std::array values {}; + std::uint64_t irRevision = 0; + bool operator==(const CompatibilityRouting& other) const noexcept + { + return values == other.values && irRevision == other.irRevision; + } + }; + CompatibilityRouting compatibilityRouting() const noexcept; + bool canMaskCompatibility(const CompatibilityRouting&) const noexcept; + void invalidateCompatibilityHistory() noexcept; + void advanceCompatibilitySmoothers(int samples) noexcept; + void resetCompatibilitySleepProof() noexcept; + CompatibilityRouting provenCompatibilityRouting {}; + int compatibilityCoveredSamples = 0; + bool compatibilityRoutingKnown = false; + bool compatibilitySleeping = false; + // Runtime diagnostics only; neither switch is a plugin/state parameter. + bool compatibilitySleepEnabled = true; + bool v2DiffusionHistoryGuardsEnabled = true; + std::uint64_t compatibilityProcessingCalls = 0; BuiltInAdditionalReverbs additionalSpaces; BuiltInPlateReverb plateSpace; BuiltInModalPlate modalPlate; @@ -689,6 +728,10 @@ class OpenStudioReverb : public juce::AudioProcessor int validPreDelayHistorySamples = 0; int validLateHistorySamples = 0; juce::SmoothedValue smoothedWetLevel; + juce::SmoothedValue smoothedStandaloneOutputGain; + juce::SmoothedValue standaloneWetRamp, standaloneDryRamp; + juce::SmoothedValue smoothedReturnCalibration; + std::vector returnCalibrationBlock; // Prepared once; shared main/cross/voice sample ramp. juce::SmoothedValue smoothedDryLevel; juce::SmoothedValue smoothedEarlyLevel; juce::SmoothedValue smoothedInputSend; @@ -728,6 +771,7 @@ class OpenStudioReverb : public juce::AudioProcessor std::array v2DiffusionOffsets {}; std::array v2DiffusionCapacities {}; std::array v2DiffusionWriteIndices {}; + std::array v2ValidDiffusionHistorySamples {}; std::array v2ShimmerPreLowPassState {}; float v2InputLowPassStateL = 0.0f; float v2InputLowPassStateR = 0.0f; @@ -950,6 +994,8 @@ class OpenStudioReverb : public juce::AudioProcessor class OpenStudioChorus : public juce::AudioProcessor { public: + std::atomic signalPath {1}; // 0 archived sound, 1 corrected signal processing + OpenStudioChorus(); ~OpenStudioChorus() override = default; @@ -1017,6 +1063,7 @@ class OpenStudioChorus : public juce::AudioProcessor void resetTailState() noexcept; private: + std::atomic retainedFeedback {0}; static constexpr int maxVoices = 6; static constexpr int maxChorusDelaySamples = 8192; static constexpr int chorusDelayBufferSize = @@ -1025,6 +1072,7 @@ class OpenStudioChorus : public juce::AudioProcessor std::array, 2> delayBuffers; int delayWritePosition = 0; float lfoPhase[maxVoices] = {}; + std::array ensemblePhaseL {}, ensemblePhaseR {}; std::array sampleHoldValue {}; std::array sampleHoldTarget {}; std::uint32_t sampleHoldRandomState = 0x8f6a2c1du; @@ -1100,6 +1148,8 @@ class OpenStudioChorus : public juce::AudioProcessor class OpenStudioSaturator : public juce::AudioProcessor { public: + std::atomic signalPath {1}; // 0 archived sound, 1 corrected signal processing + explicit OpenStudioSaturator(bool useLowLatencyOversampling = false); ~OpenStudioSaturator() override = default; @@ -1197,6 +1247,7 @@ class OpenStudioSaturator : public juce::AudioProcessor // completed its outer fade to exact dry. It deliberately does not clear // oversampling/delay buffers or rebuild coefficients. void resetRealtimeStateForEmbeddedBypass() noexcept; + static juce::var runLowCutPrecisionRegression(); private: BuiltInSaturationColour colour; @@ -1204,15 +1255,18 @@ class OpenStudioSaturator : public juce::AudioProcessor void publishColourMeters(const juce::AudioBuffer&); static constexpr int maximumOversamplingLatencySamples = 512; juce::dsp::IIR::Filter toneFilterL, toneFilterR; - juce::dsp::IIR::Filter lowCutFilterL, lowCutFilterR; + // A 20Hz biquad at the NAM island's 384kHz rate loses its DC pole + // separation in float. Keep both coefficients and delay state in double; + // only the circuit input/output boundary is narrowed to float. + juce::dsp::IIR::Filter lowCutFilterL, lowCutFilterR; double cachedSampleRate = 44100.0; int cachedBlockSize = 0; float lastToneFreq = 20000.0f; float lastLowCutFreq = 20.0f; std::vector toneCoefficientLut; - std::vector lowCutCoefficientLut; + std::vector> lowCutCoefficientLut; OpenStudioIIRCoefficientSet targetToneCoefficients {}; - OpenStudioIIRCoefficientSet targetLowCutCoefficients {}; + std::array targetLowCutCoefficients {}; bool toneCoefficientsSmoothing = false; bool lowCutCoefficientsSmoothing = false; bool lowCutBeforeSaturation = false; @@ -1463,6 +1517,13 @@ class OpenStudioNAMRack : public juce::AudioProcessor std::atomic octaverDownMix { 0.32f }; std::atomic octaverUpMix { 0.18f }; std::atomic octaverDirectMix { 1.0f }; + // Independent post-octave fuzz: old states and presets default to exact bypass. + std::atomic fuzzEnabled { 0.0f }; + std::atomic fuzzDrive { 0.5f }; + std::atomic fuzzTone { 0.5f }; + std::atomic fuzzLevelDb { 0.0f }; + std::atomic fuzzVoice { 0.0f }; + std::atomic fuzzCleanMix { 0.0f }; // PRE EQ: a clean, independently bypassable stereo equalizer placed after // the Octaver and before the native Drive/Distortion island. V19 replaces // the original seven octave bands with this guitar-focused eight-band set. @@ -1766,6 +1827,7 @@ class OpenStudioNAMRack : public juce::AudioProcessor } void setEmbeddedDriveOversamplingFactor(int factor) noexcept; int getEmbeddedDriveOversamplingFactor() const noexcept; + static juce::var runDistortionADAARegression(); int getNAMEffectsDspVersion() const noexcept { return currentNAMEffectsDspVersion; @@ -1952,7 +2014,6 @@ class OpenStudioNAMRack : public juce::AudioProcessor const NAMResamplerKernel& kernel) noexcept; private: - float sampleAt(std::int64_t absoluteIndex) const noexcept; float interpolate(double sourcePosition, const NAMResamplerKernel& kernel) const noexcept; }; @@ -2254,6 +2315,10 @@ class OpenStudioNAMRack : public juce::AudioProcessor juce::SmoothedValue smoothedCompressorToneHighGain; NAMPolyOctaver rackPolyOctaver; + NAMFuzz rackFuzz; + // Shared per-frame nonlinear-drive compensation; the octave audio/clean + // branch keeps its original level. Sized on the prepare thread only. + juce::AudioBuffer octaverExcitationGainBuffer; OpenStudioSaturator rackChaos { true }; static constexpr int maximumEmbeddedDriveLatencySamples = 512; // Drain the shared IIR oversampler incrementally after the complete @@ -2291,6 +2356,42 @@ class OpenStudioNAMRack : public juce::AudioProcessor precisionDriveBypassDelay { maximumEmbeddedDriveLatencySamples }; juce::dsp::DelayLine chaosBypassDelay { maximumEmbeddedDriveLatencySamples }; + // Callback-owned target caches. Only coefficient design is skipped: the + // sample-time smoothers and both historical voice states still advance + // exactly as before. Effective sample rate includes the shared island's + // oversampling factor, and prepare/reset invalidate every cache. + struct PrecisionDriveCoefficientCache + { + bool valid = false; + float sampleRate = 0.0f; + float drive = 0.0f, attack = 0.0f, bright = 0.0f; + bool bass = false; + float dcCoefficient = 0.0f; + float screamerInputHighPass1 = 0.0f; + float screamerInputHighPass2 = 0.0f; + float screamerOutputHighPass = 0.0f; + } precisionDriveCoefficientCache; + struct ChaosCoefficientCache + { + bool valid = false; + float sampleRate = 0.0f; + float mode = 0.0f, drive = 0.0f, tone = 0.0f, weight = 0.0f; + int profile = guitarInstrumentProfile; + float dcCoefficient = 0.0f; + float lowPreservationCoefficient = 0.0f; + float diodeDriveDb = 0.0f; + float clippingSymmetry = 0.0f; + } chaosCoefficientCache; + struct DriveGainCache + { + bool valid = false; + float control = 0.0f; + bool bass = false; + float gain = 1.0f; + } precisionDriveVolumeCache, chaosLevelCache, embeddedDriveOperatingGainCache; + void invalidateEmbeddedDriveCoefficientCaches() noexcept; + std::atomic diagnosticPrecisionDriveCoefficientDesignCount { 0 }; + std::atomic diagnosticChaosCoefficientDesignCount { 0 }; juce::SmoothedValue smoothedPrecisionDrivePower; juce::SmoothedValue @@ -2847,6 +2948,7 @@ class OpenStudioNAMRack : public juce::AudioProcessor bool active) noexcept; void processCompressorStage(juce::AudioBuffer& buffer, juce::MidiBuffer& midi); void processDualOctaverStage(juce::AudioBuffer& buffer); + NAMFuzz::Parameters getFuzzParameters() const noexcept; void processPreEQ(juce::AudioBuffer& buffer); void processEmbeddedDriveIsland( juce::AudioBuffer& buffer, @@ -2921,6 +3023,18 @@ class OpenStudioNAMRack : public juce::AudioProcessor class OpenStudioBasicSynthInstrument : public juce::AudioProcessor { public: + // Call only on the processing thread or while suspended. Reports sounding + // and releasing voices without adding a scan to the realtime callback. + int getRenderingVoiceCountForRegression() const noexcept + { + int count = 0; + for (size_t ch = 0; ch < active.size(); ++ch) + for (size_t slot = 0; slot < active[ch].size(); ++slot) + if (active[ch][slot] || envelope[ch][slot] > 0) ++count; + return count; + } + std::atomic signalPath {1}; // 0 archived sound, 1 corrected signal processing + BuiltInInstrumentPerformance performanceTelemetry; OpenStudioBasicSynthInstrument(); ~OpenStudioBasicSynthInstrument() override = default; @@ -3057,12 +3171,33 @@ class OpenStudioBasicSynthInstrument : public juce::AudioProcessor bool isOpenStudioBuiltInInstrument() const { return true; } private: + BuiltInRatePole ratePole; std::atomic tailSampleRate {192000.0}; float releaseModulationBound() const noexcept; BuiltInSynthModulation modulation; BuiltInSynthMatrix matrix; std::array,16> previousRoutes {}; std::array,16> sharedLfoOffset {}; + struct CachedPower + { + float exponent = 0, value = 1; + float powerOfTwo(float next) noexcept + { + if (next != exponent) { exponent = next; value = std::pow(2.0f, next); } + return value; + } + float exponentialOfTwo(float next) noexcept + { + if (next != exponent) { exponent = next; value = std::exp2(next); } + return value; + } + }; + struct VoiceWorkCache { CachedPower bend, attack, decay, release; }; + // Pure value caches have no envelope/voice ownership semantics. Changing a + // MIDI/MPE target or a modulated value refreshes the exact former formula. + std::array,16> voiceWorkCache {}; + std::array noteFrequencies {}; + std::array cachedPan {}, cachedPanLeft {}, cachedPanRight {}; BuiltInSynthOscillators oscillators; BuiltInSynthMPE mpe; BuiltInSynthModulation::Voice globalModulationVoice; @@ -3098,6 +3233,17 @@ class OpenStudioBasicSynthInstrument : public juce::AudioProcessor class OpenStudioPianoInstrument : public juce::AudioProcessor { public: + // Processing-thread / suspended-only observation; not UI-thread telemetry. + int getRenderingVoiceCountForRegression() const noexcept + { + int count = 0; + for (size_t ch = 0; ch < active.size(); ++ch) + for (size_t slot = 0; slot < active[ch].size(); ++slot) + if (active[ch][slot] || envelope[ch][slot] > 0) ++count; + return count; + } + std::atomic signalPath {1}; // 0 archived sound, 1 corrected signal processing + BuiltInInstrumentPerformance performanceTelemetry; OpenStudioPianoInstrument(); ~OpenStudioPianoInstrument() override = default; @@ -3172,6 +3318,7 @@ class OpenStudioPianoInstrument : public juce::AudioProcessor std::array, 16> releasing {}; std::array, 16> sustained {}; std::array, 16> phase {}; + std::array, 128>, 16> fractionalPhase {}; std::array, 16> velocity {}; std::array, 16> envelope {}; std::array, 16> ageSamples {}; @@ -3190,6 +3337,17 @@ class OpenStudioPianoInstrument : public juce::AudioProcessor class OpenStudioCleanGuitarInstrument : public juce::AudioProcessor { public: + // Processing-thread / suspended-only observation; not UI-thread telemetry. + int getRenderingVoiceCountForRegression() const noexcept + { + int count = 0; + for (size_t ch = 0; ch < active.size(); ++ch) + for (size_t slot = 0; slot < active[ch].size(); ++slot) + if (active[ch][slot] || envelope[ch][slot] > 0) ++count; + return count; + } + std::atomic signalPath {1}; // 0 archived sound, 1 corrected signal processing + BuiltInInstrumentPerformance performanceTelemetry; BuiltInGuitarPerformance guitarPerformance; OpenStudioCleanGuitarInstrument(); @@ -3264,6 +3422,7 @@ class OpenStudioCleanGuitarInstrument : public juce::AudioProcessor bool isOpenStudioBuiltInInstrument() const { return true; } private: + BuiltInRatePole ratePole; std::atomic tailSampleRate {192000.0}; BuiltInCoupledBody coupledNetwork; juce::SmoothedValue coupledAmount; @@ -3282,6 +3441,7 @@ class OpenStudioCleanGuitarInstrument : public juce::AudioProcessor std::array, 16> active {}; std::array, 16> releasing {}; std::array, 16> phase {}; + std::array, 128>, 16> fractionalPhase {}; std::array, 16> velocity {}; std::array, 16> envelope {}; std::array, 16> pluckFilter {}; @@ -3308,6 +3468,15 @@ class OpenStudioCleanGuitarInstrument : public juce::AudioProcessor class OpenStudioDrumInstrument : public juce::AudioProcessor { public: + // Processing-thread / suspended-only observation; not UI-thread telemetry. + int getRenderingVoiceCountForRegression() const noexcept + { + int count = 0; + for (const auto& channel : active) for (bool voice : channel) if (voice) ++count; + return count; + } + std::atomic signalPath {1}; // 0 archived sound, 1 corrected signal processing + BuiltInInstrumentPerformance performanceTelemetry; OpenStudioDrumInstrument(); ~OpenStudioDrumInstrument() override = default; @@ -3344,7 +3513,16 @@ class OpenStudioDrumInstrument : public juce::AudioProcessor bool acceptsMidi() const override { return true; } bool producesMidi() const override { return false; } bool isMidiEffect() const override { return false; } - double getTailLengthSeconds() const override { float maximum=1;for(const auto& value:pieceDecay)maximum=juce::jmax(maximum,value.load());return articulationEngine.load()>=.5f||maximum>1?14.0*maximum:2.0; } + double getTailLengthSeconds() const override + { + // Longest Legacy crash: tau=1.55s, velocity multiplier<=1.24, + // shutdown at exp(-t/tau)<0.0002: 16.38s before piece decay scaling. + double maximum = 1; + for (const auto& value : pieceDecay) + maximum = juce::jmax(maximum, static_cast(juce::jlimit(.1f, 4.0f, value.load()))); + return 17.0 * maximum; + } + double getMaximumTailLengthSeconds() const noexcept { return 68.0; } int getNumPrograms() override { return 1; } int getCurrentProgram() override { return 0; } @@ -3372,6 +3550,7 @@ class OpenStudioDrumInstrument : public juce::AudioProcessor std::array, 16> choking {}; std::array, 16> active {}; std::array, 16> phase {}; + std::array, 128>, 16> fractionalPhase {}; std::array, 16> velocity {}; std::array, 16> ageSamples {}; std::array hihatPedal {}; diff --git a/Source/BuiltInFilterTail.h b/Source/BuiltInFilterTail.h new file mode 100644 index 0000000..a1632e6 --- /dev/null +++ b/Source/BuiltInFilterTail.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include + +namespace BuiltInFilterTail +{ +// A conservative modal decay allowance (180 dB, doubled for polynomial factors +// from repeated poles). This is an export bound, not an acoustic RT60 estimate. +inline double pole(double radius, double rate) noexcept +{ + if (!(radius > 0)) return 0; + if (!std::isfinite(radius) || radius >= 1) return 120; + return 2 * std::log(1.0e-9) / (std::log(radius) * juce::jmax(1.0, rate)); +} +template +double biquad(const Coefficients& c, double rate) noexcept +{ + if (c[0] == 1 && c[1] == c[3] && c[2] == c[4]) return 0; + const double a1 = c[3], a2 = c[4], discriminant = a1 * a1 - 4 * a2; + const double radius = discriminant < 0 ? std::sqrt(std::abs(a2)) + : .5 * juce::jmax(std::abs(-a1 + std::sqrt(discriminant)), std::abs(-a1 - std::sqrt(discriminant))); + return pole(radius, rate); +} +inline double retain(std::atomic& previous, double requested) noexcept +{ + double value = previous.load(std::memory_order_relaxed); + while (value < requested && !previous.compare_exchange_weak(value, requested, std::memory_order_relaxed)) {} + return juce::jmax(value, requested); +} +} diff --git a/Source/BuiltInIRPreview.h b/Source/BuiltInIRPreview.h index fa6c3e4..2d0db90 100644 --- a/Source/BuiltInIRPreview.h +++ b/Source/BuiltInIRPreview.h @@ -48,6 +48,13 @@ class BuiltInIRPreview result->setProperty("preparing",owns&&preparing);result->setProperty("playing",owns&&state.load()>0&&!stopRequested.load()); result->setProperty("progress",owns?progress.load():0.0f);return result; } + // Preparation alone is not audible. Published audio, including its stop + // ramp, stays active until render retires it; a later publication is seen + // by the next callback without another mutable activity flag. + bool mayRender() const noexcept + { + return state.load(std::memory_order_acquire) > 0; + } void render(float* const* output,int channels,int count,double sampleRate) noexcept { int ready=1;if(!state.compare_exchange_strong(ready,2))return; diff --git a/Source/BuiltInInfiniteHoldBudget.h b/Source/BuiltInInfiniteHoldBudget.h new file mode 100644 index 0000000..768abfd --- /dev/null +++ b/Source/BuiltInInfiniteHoldBudget.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include + +// A continuing-input lossless reservoir cannot accumulate unbounded energy. +// Limit the *linked writes*, before they enter its history, using a smooth knee. +// Below the knee this is an exact bypass. New input remains part of every +// write; this never turns Infinite into input-rejecting Freeze. No state or +// parameter is persisted, and finite/Freeze paths do not call the governor. +class BuiltInInfiniteHoldBudget +{ +public: + void reset() noexcept + { + interventions = 0; + maximumStored = 0; + } + + template + void process(std::array& samples, float limit) noexcept + { + float peak = 0; + for (const float sample : samples) + peak = std::max(peak, std::abs(sample)); + + const float knee = limit * .75f; + if (peak > knee) + { + const float excess = peak - knee, span = limit - knee; + const float mapped = knee + span * (excess / (span + excess)); + const float gain = mapped / peak; + for (auto& sample : samples) + sample *= gain; + + ++interventions; + peak = mapped; + } + maximumStored = std::max(maximumStored, peak); + } + + std::uint64_t interventionCount() const noexcept { return interventions; } + float storedPeak() const noexcept { return maximumStored; } + +private: + std::uint64_t interventions = 0; + float maximumStored = 0; +}; diff --git a/Source/BuiltInInstrumentPreview.h b/Source/BuiltInInstrumentPreview.h index 68e6c1f..87841e8 100644 --- a/Source/BuiltInInstrumentPreview.h +++ b/Source/BuiltInInstrumentPreview.h @@ -49,10 +49,23 @@ class BuiltInInstrumentPreview } return false; } + // Existing publication states are the source of truth: preparing leases + // become visible only after state 1 is published, and stop/release tails + // remain renderable until the callback retires the lease. A publication + // after this snapshot is picked up by the next callback. + bool mayRender() const noexcept + { + for (const auto& slot : slots) + if (slot.state.load(std::memory_order_acquire) > 0) + return true; + return false; + } void render(float* const* output, int channels, int count, double rate) { for (auto& slot : slots) { + if (slot.state.load(std::memory_order_acquire) != 1) + continue; int ready = 1; if (!slot.state.compare_exchange_strong(ready, 2)) continue; bool stop = slot.stop.load() || slot.source.expired() || slot.rate != rate @@ -62,7 +75,7 @@ class BuiltInInstrumentPreview for (int start = 0; start < count; start += 256) { const int length = juce::jmin(256, count - start); - slot.audio.clear(); slot.midi.clear(); + slot.audio.clear(0, length); slot.midi.clear(); int previewChannel = 1; if (const auto* synth = dynamic_cast(slot.processor.get())) if (synth->mpeEnabled.load() >= .5f) diff --git a/Source/BuiltInNonlinearTail.h b/Source/BuiltInNonlinearTail.h index 9c79b22..82769dd 100644 --- a/Source/BuiltInNonlinearTail.h +++ b/Source/BuiltInNonlinearTail.h @@ -1,4 +1,6 @@ #pragma once +#include "BuiltInReverbDamping.h" +#include "BuiltInInfiniteHoldBudget.h" #include #include #include @@ -47,7 +49,7 @@ class BuiltInNonlinearTail for (size_t stage = 0; stage < smear.size(); ++stage) diffusers[channel][stage].prepare(static_cast(rate * (smear[stage] + static_cast(channel) * .0003f)) + 1); for (auto& gain : feedback) gain.reset(rate, .05); - holdWeight.reset(rate,.05); + holdWeight.reset(rate,.05);dampingRamp.reset(rate,.05); reset(); } void reset() noexcept @@ -58,9 +60,9 @@ class BuiltInNonlinearTail void resetLate() noexcept { for (auto& line : tank) line.reset(); - damping.fill(0); initialized = false;holdWeight.setCurrentAndTargetValue(0); + damping.fill(0); initialized = false;holdWeight.setCurrentAndTargetValue(0);holdBudget.reset(); } - void configure(float decay, float tone, bool hold=false) noexcept + void configure(float decay, float tone, bool hold=false, bool corrected=false) noexcept { const float seconds = juce::jlimit(.1f, 20.0f, decay); for (size_t i = 0; i < tank.size(); ++i) @@ -69,6 +71,9 @@ class BuiltInNonlinearTail if (initialized) feedback[i].setTargetValue(gain); else feedback[i].setCurrentAndTargetValue(gain); } dampingCoefficient = juce::jlimit(.03f, .95f, .95f - tone * .9f); + correctedControls=corrected; + const float target=BuiltInReverbDamping::alpha(dampingCoefficient,rate); + if(initialized)dampingRamp.setTargetValue(target);else dampingRamp.setCurrentAndTargetValue(target); if(initialized)holdWeight.setTargetValue(hold?1.0f:0.0f);else holdWeight.setCurrentAndTargetValue(hold?1.0f:0.0f); initialized = true; } @@ -88,8 +93,10 @@ class BuiltInNonlinearTail } return result; } - std::array process(const std::array& input, float depthSamples = 0, float phaseSine = 0, float phaseCosine = 1) noexcept + std::array process(const std::array& input, float depthSamples = 0, float phaseSine = 0, float phaseCosine = 1, float infiniteLimit = 0) noexcept { + const float nextTone=dampingRamp.getNextValue(); + if(correctedControls)dampingCoefficient=nextTone; const float held=holdWeight.getNextValue(),tone=dampingCoefficient+(1-dampingCoefficient)*held; std::array taps {}; float sum = 0; @@ -99,23 +106,29 @@ class BuiltInNonlinearTail sum += taps[i]; } std::array wet {}; + std::array nextSamples {}; for (size_t i = 0; i < tank.size(); ++i) { const float reflection = taps[i] - .25f * sum; damping[i] += tone * (reflection - damping[i]); const float next=input[i % 2] * .22f + damping[i] * feedback[i].getNextValue(); - tank[i].write(held>0?juce::jlimit(-4.0f,4.0f,next):next); + nextSamples[i]=next; wet[i % 2] += taps[i] * (i < 4 ? .35f : -.35f); } + if(infiniteLimit>0)holdBudget.process(nextSamples,infiniteLimit); + for(size_t i=0;i0?juce::jlimit(-4.0f,4.0f,nextSamples[i]):nextSamples[i]); return wet; } + const BuiltInInfiniteHoldBudget& infiniteBudget()const noexcept{return holdBudget;} private: float rate = 48000, dampingCoefficient = .5f; - bool initialized = false; - juce::SmoothedValue holdWeight; + bool initialized = false, correctedControls = false; + juce::SmoothedValue holdWeight,dampingRamp; std::array tank; std::array, 2> diffusers; std::array damping {}; std::array, 8> phaseRotations {}; std::array, 8> feedback; + BuiltInInfiniteHoldBudget holdBudget; }; diff --git a/Source/BuiltInPlateReverb.h b/Source/BuiltInPlateReverb.h index cc8c4e0..82b7516 100644 --- a/Source/BuiltInPlateReverb.h +++ b/Source/BuiltInPlateReverb.h @@ -1,7 +1,10 @@ #pragma once +#include "BuiltInReverbDamping.h" #include #include "BuiltInReverbSpillover.h" +#include "BuiltInReverbSparseDiffusion.h" #include +#include #include // Standalone algorithmic plate, derived from Dattorro (JAES 45/9, 1997), @@ -20,6 +23,13 @@ class BuiltInPlateReverb int decayFilter = 0; // Legacy damping, Off, or low-pass. float decayCutoff = 8000; bool outputCutOff = false; + bool refined = false; + bool correctedControls = false; + bool operator==(const Settings& other) const noexcept + { + return std::tie(decay,damping,diffusion,preDelay,lowCut,highCut,width,modulation,freeze,infiniteInput,decayFilter,decayCutoff,outputCutOff,refined,correctedControls) + == std::tie(other.decay,other.damping,other.diffusion,other.preDelay,other.lowCut,other.highCut,other.width,other.modulation,other.freeze,other.infiniteInput,other.decayFilter,other.decayCutoff,other.outputCutOff,other.refined,other.correctedControls); + } }; private: @@ -84,6 +94,8 @@ class BuiltInPlateReverb bool initialized = false; int drain = 0; Settings settings; + BuiltInReverbSparseDiffusion readoutDiffusion; + bool configuredActive = false; void prepare(double sampleRate, float characterScale) { @@ -103,6 +115,7 @@ class BuiltInPlateReverb } for (auto* smoother : { &diffusion, &dampingPole, &highPole, &lowPole, &width, &modulation, &send, &preSamples }) smoother->reset(sampleRate, .05); + readoutDiffusion.prepare(sampleRate); reset(); } void reset() @@ -111,10 +124,14 @@ class BuiltInPlateReverb for (auto& line : loop) line.reset(); predelay.reset(); feedback.fill(0); damp.fill(0); hp.fill(0); lp.fill(0); phase = 0; drain = 0; initialized = false; + readoutDiffusion.reset(); } void configure(Settings next, bool active) { + const auto previous = settings; if (active) settings = next; + if (initialized && configuredActive == active && settings == previous) return; + configuredActive = active; const bool hold = active && settings.freeze; const auto set = [this](auto& smoother, float target) { @@ -131,13 +148,14 @@ class BuiltInPlateReverb const float cutoff = 18000.0f * std::pow(1.0f / 36.0f, settings.damping); set(dampingPole, hold || settings.decayFilter == 1 ? 0 : settings.decayFilter == 2 ? std::exp(-juce::MathConstants::twoPi * juce::jmin(rate * .45f, settings.decayCutoff) / rate) - : settings.damping * std::exp(-juce::MathConstants::twoPi * cutoff / rate)); + : BuiltInReverbDamping::legacyTone(settings.damping, cutoff, rate, settings.correctedControls)); set(lowPole, std::exp(-juce::MathConstants::twoPi * settings.lowCut / rate)); set(highPole, settings.outputCutOff ? 0 : std::exp(-juce::MathConstants::twoPi * juce::jmin(rate * .45f, settings.highCut) / rate)); set(width, settings.width); set(modulation, hold ? 0 : settings.modulation * rate * .00045f); set(send, active && (!hold || settings.infiniteInput) ? 1.0f : 0.0f); set(preSamples, settings.preDelay * .001f * rate); + readoutDiffusion.configure(settings.refined); initialized = true; } std::array process(float mono, bool active) @@ -159,7 +177,7 @@ class BuiltInPlateReverb for (size_t ch = 0; ch < 2; ++ch) { const size_t offset = ch * 4; - const float excursion = depth * static_cast(std::sin(phase + static_cast(ch) * juce::MathConstants::halfPi)); + const float excursion = depth == 0 ? 0 : depth * static_cast(std::sin(phase + static_cast(ch) * juce::MathConstants::halfPi)); const float excitation = signal * .3f + feedback[ch] * singleSampleLoss; float value = loop[offset].allpass(settings.freeze && settings.infiniteInput ? juce::jlimit(-8.0f, 8.0f, excitation) : excitation, -.7f, excursion); value = loop[offset + 1].delay(value); @@ -181,6 +199,7 @@ class BuiltInPlateReverb lp[ch] = value + lpPole * (lp[ch] - value); output[ch] = lp[ch]; } + output = readoutDiffusion.process(output); const float mid = (output[0] + output[1]) * .5f; const float side = (output[0] - output[1]) * .5f * width.getNextValue(); return { mid + side, mid - side }; @@ -235,6 +254,17 @@ class BuiltInPlateReverb for(size_t i=0;i(tanks[i].drain)/tanks[i].rate); return seconds; } + bool isDormant() const noexcept + { + for (const auto& tank : tanks) if (tank.drain > 0) return false; + return true; + } + juce::uint64 sparseProcessedFrames() const noexcept + { + juce::uint64 frames = 0; + for (const auto& tank : tanks) frames += tank.readoutDiffusion.processedFrames(); + return frames; + } std::array process(float left, float right) { if (!prepared) return {}; @@ -243,8 +273,10 @@ class BuiltInPlateReverb std::array output {}; for (size_t i = 0; i < tanks.size(); ++i) { - const auto wet = tanks[i].process(mono, weights[i].getTargetValue() > 0); + const bool active = weights[i].getTargetValue() > 0; const float weight = weights[i].getNextValue(),wetWeight=spillWeights.next(i); + if (!active && wetWeight <= 0 && tanks[i].drain > 0) tanks[i].reset(); + const auto wet = tanks[i].process(mono, active); output[0] += wet[0] * wetWeight; output[1] += wet[1] * wetWeight; output[2] += weight; } return output; diff --git a/Source/BuiltInRatePole.h b/Source/BuiltInRatePole.h new file mode 100644 index 0000000..30c1476 --- /dev/null +++ b/Source/BuiltInRatePole.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include +// A 48 kHz reference one-pole mapped once in prepare, with bounded interpolation. +struct BuiltInRatePole +{ + std::array table {}; + bool reference = true; + void prepare(double rate) noexcept + { + reference = rate == 48000.0; + for (size_t i = 0; i < table.size(); ++i) + table[i] = static_cast(1 - std::pow(1 - static_cast(i) / 2048, 48000.0 / rate)); + } + float map(float value) const noexcept + { + if (!(value > 0)) return 0; + if (value >= 1) return 1; + if (reference) return value; + const float position = value * 2048; + const auto index = static_cast(position); + return table[index] + (table[index + 1] - table[index]) * (position - static_cast(index)); + } +}; +inline float builtInFractionalPhase(float& phase, float increment) noexcept +{ + const float result = phase; + phase += increment; + phase -= std::floor(phase); + return result; +} diff --git a/Source/BuiltInRetroReverb.h b/Source/BuiltInRetroReverb.h index e501540..6bb48c1 100644 --- a/Source/BuiltInRetroReverb.h +++ b/Source/BuiltInRetroReverb.h @@ -1,6 +1,8 @@ #pragma once #include +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbSpillover.h" +#include "BuiltInSettledReverbTap.h" #include "BuiltInVintageConverter.h" #include #include @@ -19,7 +21,7 @@ class BuiltInRetroReverb { float size=.5f,damping=.5f,diffusion=.5f,preDelay=0,lowCut=20,highCut=20000,width=1,early=.5f; float decay=2,attack=.25f,inputDiffusion=.65f,depth=.25f,speed=.3f,era=2,bassRatio=1,bassFrequency=500,aperture=.25f; - bool hold=false,holdInput=false; + bool hold=false,holdInput=false,refined=false; }; // Counts, topology, delay scale, input stages and ensemble voice count are // structural differences. Attack controls early/tail balance in Reflection @@ -63,11 +65,13 @@ class BuiltInRetroReverb Delay delay,inner;float base=0,low=0,damped=0;std::array apertureHistory{};size_t aperturePosition=0; double sine=0,cosine=1,rotateSine=0,rotateCosine=1; juce::SmoothedValue length,loss,bassLoss; - void reset(size_t index) noexcept {delay.reset();inner.reset();low=damped=0;apertureHistory.fill(0);aperturePosition=0;sine=std::sin(static_cast(index)*1.719);cosine=std::cos(static_cast(index)*1.719);} + BuiltInSettledReverbTap settledTap,innerSettledTap; + void reset(size_t index) noexcept {delay.reset();inner.reset();settledTap.reset();innerSettledTap.reset();low=damped=0;apertureHistory.fill(0);aperturePosition=0;sine=std::sin(static_cast(index)*1.719);cosine=std::cos(static_cast(index)*1.719);} }; struct Engine { std::array lines;std::array pre,reflections,loop; + std::array loopSettledTaps; std::array,2> diffusers; std::array outputLow{},outputHigh{},loopLow{},loopDamped{}; std::array,2> reflectionTimes{},reflectionGains{}; @@ -83,11 +87,13 @@ class BuiltInRetroReverb // Only a topology's used lines need sample storage. line.delay.prepare(i(profile.lines)?static_cast(rate*(line.base*1.5f+.015f)):1); line.inner.prepare(profile.core==1&&i<6?static_cast(rate*(line.base*.51f+.015f)):1); + line.settledTap.prepare(rate);line.innerSettledTap.prepare(rate); for(auto* value:{&line.length,&line.loss,&line.bassLoss})value->reset(rate,.1); } for(auto& value:pre)value.prepare(static_cast(rate*.501)); for(auto& value:reflections)value.prepare(static_cast(rate*(profile.core==2?2.01:.75))); for(auto& value:loop)value.prepare(profile.core==1?static_cast(rate*.15):1); + for(auto& value:loopSettledTaps)value.prepare(rate); for(auto& channel:diffusers)for(auto& value:channel)value.prepare(static_cast(rate*(profile.spread*.65f+.002f))); for(auto* value:{&size,&preSamples,&earlyDiffusion,&lateDiffusion,&onset,&attack,&depth,&damping,&bassPole,&lowPole,&highPole,&width,&earlyLevel,&hold,&excitation,&aperture,&loopGain,&loopBassGain})value->reset(rate,.05); // Deterministic, nonuniform stereo reflection pattern, prepared once. @@ -97,7 +103,7 @@ class BuiltInRetroReverb reset();frames=0; } void reset() noexcept - {for(size_t i=0;i(-.5*line.sine-.8660254*line.cosine)))/3; const double next=line.sine*line.rotateCosine+line.cosine*line.rotateSine;line.cosine=line.cosine*line.rotateCosine-line.sine*line.rotateSine;line.sine=next; if((frames&1023u)==0){const double norm=std::hypot(line.sine,line.cosine);line.sine/=norm;line.cosine/=norm;} - return value; + if(!line.settledTap.needsProcessing(settings.refined))return value; + return line.settledTap.process(value,length,settings.refined,settings.refined&&amount==0&&!line.length.isSmoothing(),[&line](float tap) noexcept {return line.delay.read(tap);}); } float colour(float input,int bits) const noexcept { @@ -192,7 +199,10 @@ class BuiltInRetroReverb const float gain=loopGain.getNextValue(),bassGain=loopBassGain.getNextValue();std::array returned{}; for(size_t ch=0;ch<2;++ch) { - float value=loop[ch].read(static_cast(rate)*(ch==0?.053f:.071f)*scale); + const float loopLength=static_cast(rate)*(ch==0?.053f:.071f)*scale; + float value=loop[ch].read(loopLength); + if(loopSettledTaps[ch].needsProcessing(settings.refined)) + value=loopSettledTaps[ch].process(value,loopLength,settings.refined,settings.refined&&motion==0&&!size.isSmoothing(),[this,ch](float tap) noexcept {return loop[ch].read(tap);}); loopLow[ch]=value+bass*(loopLow[ch]-value);value=value*(gain+(1-gain)*held)+loopLow[ch]*(bassGain-gain)*(1-held); loopDamped[ch]=value+.55f*(loopDamped[ch]-value);returned[ch]=held>.999f?value:colour(value+damp*(loopDamped[ch]-value),bits); } @@ -205,7 +215,16 @@ class BuiltInRetroReverb float delayed=readLine(line,length,juce::jmin(motion,length*.15f)); if(profile.aperture){line.apertureHistory[line.aperturePosition]=delayed;line.aperturePosition=(line.aperturePosition+1)%8;float mean=0;for(float sample:line.apertureHistory)mean+=sample*.125f;delayed+=apertureAmount*(mean-delayed);} const float out=delayed-lateCoefficient*value;const float innerInput=value+lateCoefficient*out; - line.delay.write(line.inner.allpass(innerInput,length*.31f,lateCoefficient*.83f));value=out; + const float innerLength=length*.31f,innerCoefficient=lateCoefficient*.83f; + float innerOutput; + if(line.innerSettledTap.needsProcessing(settings.refined)) + { + const float innerDelayed=line.innerSettledTap.process(line.inner.read(innerLength),innerLength,settings.refined,settings.refined&&motion==0&&!line.length.isSmoothing(),[&line](float tap) noexcept {return line.inner.read(tap);}); + innerOutput=innerDelayed-innerCoefficient*innerInput; + line.inner.write(innerInput+innerCoefficient*innerOutput); + } + else innerOutput=line.inner.allpass(innerInput,innerLength,innerCoefficient); + line.delay.write(innerOutput);value=out; output[ch]+=out/3; } loop[ch].write(value); @@ -244,8 +263,8 @@ class BuiltInRetroReverb settings.decay=safe(settings.decay,.1f,70,2);settings.attack=safe(settings.attack,0,1,.25f);settings.inputDiffusion=safe(settings.inputDiffusion,0,1,.65f);settings.depth=safe(settings.depth,0,1,.25f);settings.speed=safe(settings.speed,.05f,8,.3f);settings.era=safe(settings.era,0,2,2);settings.bassRatio=safe(settings.bassRatio,.25f,4,1);settings.bassFrequency=safe(settings.bassFrequency,100,10000,500);settings.aperture=safe(settings.aperture,0,1,.25f); for(size_t i=0;i(i);weights[i].setTargetValue(active?1.0f:0.0f);engines[i].configure(settings,active,spillover);spills.configure(i,active,spillover,engines[i].running());} } - std::array process(float left,float right) noexcept - {if(!prepared)return {};std::array result{};for(size_t i=0;i process(float left,float right,float returnBlend=0) noexcept + {if(!prepared)return {};std::array result{};for(size_t i=0;i(i),returnBlend);result[0]+=wet[0]*returnWeight;result[1]+=wet[1]*returnWeight;result[2]+=selection;}return result;} double retiringTailSeconds() const noexcept {double result=0;for(const auto& engine:engines)result=juce::jmax(result,engine.tail());return result;} juce::uint64 processingFrames(size_t kind,size_t era) const noexcept{return engines[kind].engines[era].frames;} }; diff --git a/Source/BuiltInReverbDamping.h b/Source/BuiltInReverbDamping.h new file mode 100644 index 0000000..ad2d42a --- /dev/null +++ b/Source/BuiltInReverbDamping.h @@ -0,0 +1,25 @@ +#pragma once +#include + +// Preserve the 48 kHz pole's time constant. Call during configure, never in a +// sample loop. This does not claim identical high-frequency digital responses +// near Nyquist or compensate losses elsewhere in a reverb network. +namespace BuiltInReverbDamping +{ +inline float pole(float referencePole, double sampleRate) noexcept +{ + if (referencePole <= 0) return 0; // Exact explicit bypass. + if (sampleRate == 48000.0) return referencePole; + return static_cast(std::pow(static_cast(referencePole), 48000.0 / sampleRate)); +} +inline float alpha(float referenceAlpha, double sampleRate) noexcept +{ + return sampleRate == 48000.0 ? referenceAlpha : 1.0f - pole(1.0f - referenceAlpha, sampleRate); +} +inline float legacyTone(float damping, float cutoff, double sampleRate, bool corrected) noexcept +{ + constexpr float twoPi = 6.2831853071795864769f; + if (!corrected) return damping * std::exp(-twoPi * cutoff / static_cast(sampleRate)); + return pole(damping * std::exp(-twoPi * cutoff / 48000.0f), sampleRate); +} +} diff --git a/Source/BuiltInReverbFactoryPresets.h b/Source/BuiltInReverbFactoryPresets.h new file mode 100644 index 0000000..683f88f --- /dev/null +++ b/Source/BuiltInReverbFactoryPresets.h @@ -0,0 +1,434 @@ +#pragma once +#include "BuiltInEffects2.h" + +// Explicit, opt-in voices. Generating one never changes constructor defaults, +// a live processor, or a user's stored preset file. The collection deliberately +// authors all type memories; individual voices author only their selected type. +// These parameter choices require listening; they are not acoustic models or a +// perceptually calibrated loudness match. +namespace OpenStudioReverbFactoryPresets +{ +enum class Kind { Existing, Individual, Collection }; +struct Voice +{ + const char* name; + const char* character; + int type; + // Size, damping, pre-delay, diffusion, low/high cut, early, decay. + std::array controls; + float width, wet, outputTrimDb; + Kind kind = Kind::Existing; +}; + +// Keep this table and its existing build path stable. New profiles are separate +// choices, so previously selected factory names retain their parameter values. +inline constexpr std::array existingVoices {{ + { "Factory - Tight room", "Short, narrow early field with little late wash", 0, + { .16f, .28f, 3, .25f, 100, 14000, .85f, .38f }, .55f, .25f, 0 }, + { "Factory - Long dark hall", "Broad, delayed tail with restrained high frequencies", 1, + { .85f, .78f, 42, .90f, 130, 4800, .18f, 5.8f }, 1, .33f, 0 }, + { "Factory - Bright short plate", "Compact plate tank with bright, dense reflections", 2, + { .32f, .12f, 14, .88f, 180, 16000, .12f, 1.1f }, .95f, .33f, 0 }, + { "Factory - Metallic spring", "Dispersive two-spring response with modest motion", 4, + { .40f, .22f, 0, .45f, 100, 9500, .25f, 1.7f }, .75f, .30f, 0 }, + { "Factory - Sparse image room", "Off-centre finite image-source reflection field", 16, + { .72f, .18f, 7, .20f, 100, 14000, .90f, 1.0f }, .85f, .45f, 0 }, + { "Factory - Modulated cloud", "Slow, diffuse cloud with a long softened tail", 19, + { .82f, .60f, 65, .92f, 180, 6200, .10f, 8.0f }, 1, .28f, -3 }, + { "Factory - Grand gallery", "Spacious digital field with a spread onset", 38, + { .78f, .35f, 28, .80f, 150, 10500, .70f, 7.5f }, 1, .33f, 0 }, + { "Factory - Shimmer halo", "Long pitch-shifted tail above a dark diffuse field", 5, + { .68f, .58f, 55, .75f, 240, 7500, .10f, 6.5f }, 1, .22f, -3 } +}}; + +// Fixed Output trims reserve roughly 3 dB on the original-level shared guitar +// at Dry 70 / Wet 100. They scale the whole mix, never feedback or wet/dry ratio. +// Other source levels and held accumulation still require normal gain staging. +// One deliberately authored profile per selector type. Shared controls are not +// equally meaningful in every topology: family controls below set the actual +// Retro/Cloud decay, Spatial delay and finite envelopes where appropriate. +inline constexpr std::array profiles {{ + { "Factory - Voiced Room", "Compact bright room; close early image, short restrained tail", 0, + { .12f, .15f, 4, .32f, 110, 15500, .88f, .42f }, .62f, .34f, -2, Kind::Individual }, + { "Factory - Voiced Hall", "Soft dark hall separated from the source by a delayed onset", 1, + { .77f, .68f, 38, .86f, 100, 6900, .20f, 4.8f }, 1, .33f, -3, Kind::Individual }, + { "Factory - Voiced Plate", "Bright dense plate with a clear attack and subtle color", 2, + { .36f, .17f, 12, .94f, 190, 16500, .10f, 1.65f }, .94f, .32f, 0, Kind::Individual }, + { "Factory - Voiced Chamber", "Mid-sized warm chamber with prominent early returns", 3, + { .39f, .47f, 19, .58f, 75, 10300, .72f, 1.15f }, .74f, .34f, -5, Kind::Individual }, + { "Factory - Voiced Spring", "Driven three-spring twang with strong dispersion and limited motion", 4, + { .30f, .32f, 0, .28f, 140, 7200, .22f, 1.85f }, .68f, .30f, -4, Kind::Individual }, + { "Factory - Voiced Shimmer", "Octave/fifth pitched input into a dark long tail; no pitch recirculation", 5, + { .80f, .59f, 78, .91f, 290, 6800, .06f, 11.5f }, 1, .26f, -5, Kind::Individual }, + { "Factory - Voiced Nonlinear", "Short reverse envelope with a small diffuse release", 6, + { .28f, .24f, 9, .38f, 170, 12500, .12f, .68f }, .82f, .35f, -13, Kind::Individual }, + { "Factory - Voiced Convolution", "Portable generated room IR; trimmed, tightened and early-weighted", 7, + { .22f, .30f, 6, .42f, 95, 11900, .78f, .70f }, .78f, .35f, -2, Kind::Individual }, + { "Factory - Voiced Church", "Long dark sanctuary with a slow onset and sustained low body", 8, + { .95f, .76f, 86, .96f, 80, 4200, .22f, 12.0f }, 1, .28f, -2, Kind::Individual }, + { "Factory - Voiced Ambience", "Very short bright close field with little late buildup", 9, + { .09f, .09f, 0, .20f, 160, 17800, .95f, .24f }, .46f, .42f, -4, Kind::Individual }, + { "Factory - Voiced Vintage hall", "Dark early-digital hall with deliberate slow chorus and buildup", 10, + { .71f, .57f, 31, .75f, 130, 7700, .18f, 3.9f }, .92f, .34f, -5, Kind::Individual }, + { "Factory - Voiced Vintage random", "Compact brighter randomized tail with faster stationary-tap motion", 11, + { .42f, .30f, 17, .91f, 210, 13100, .28f, 1.75f }, 1, .34f, -4, Kind::Individual }, + { "Factory - Voiced Contour room", "Sparse integral-tap room with a clearly delayed early image", 12, + { .21f, .23f, 55, .36f, 125, 14500, .80f, .72f }, .65f, .39f, -3, Kind::Individual }, + { "Factory - Voiced Open hall", "Broad open field with a detached dense arrival", 13, + { .88f, .37f, 145, .83f, 95, 10400, .26f, 5.2f }, 1, .32f, -3, Kind::Individual }, + { "Factory - Voiced Diffuse loop", "Audible repeating allpass loop, darkened with restrained diffusion", 14, + { .54f, .72f, 235, .41f, 190, 5600, .14f, 2.8f }, .90f, .37f, -2, Kind::Individual }, + { "Factory - Voiced Magnetic heads", "Three uneven dark echo heads with feedback and slow motion", 15, + { .48f, .66f, 13, .24f, 150, 6100, .24f, 2.4f }, .81f, .36f, -9, Kind::Individual }, + { "Factory - Voiced Positioned room", "Asymmetric finite image reflections from an off-centre source", 16, + { .66f, .19f, 8, .12f, 70, 15200, .93f, .82f }, .86f, .42f, 0, Kind::Individual }, + { "Factory - Voiced Rising space", "Long wet swell with an intentionally softened entrance", 17, + { .73f, .64f, 28, .79f, 220, 6400, .10f, 6.7f }, 1, .33f, -7, Kind::Individual }, + { "Factory - Voiced Bloom field", "Shorter feedback bloom with a pulsing delayed arrival", 18, + { .57f, .43f, 47, .84f, 160, 9800, .12f, 4.4f }, .96f, .32f, -5, Kind::Individual }, + { "Factory - Voiced Cloud field", "Deep slowly moving cloud with a very long filtered late wash", 19, + { .91f, .72f, 92, .97f, 250, 5100, .05f, 16.0f }, 1, .26f, -5, Kind::Individual }, + { "Factory - Voiced Vowel hall", "Resonant rounded vowel field with slow formant motion", 20, + { .63f, .35f, 23, .69f, 180, 8800, .16f, 3.3f }, .93f, .30f, -1, Kind::Individual }, + { "Factory - Voiced Drifting hall", "Warm saturated long hall with slow wow and strong low decay", 21, + { .82f, .63f, 44, .87f, 90, 5900, .16f, 5.6f }, 1, .32f, -7, Kind::Individual }, + { "Factory - Voiced Drifting chamber", "Tight chamber with quick flutter and little wow", 22, + { .25f, .29f, 7, .41f, 170, 11700, .63f, .85f }, .68f, .37f, -2, Kind::Individual }, + { "Factory - Voiced Drifting diffuse", "Smoothed medium field with pronounced tape motion", 23, + { .60f, .48f, 26, .98f, 200, 8200, .12f, 2.7f }, .97f, .34f, -3, Kind::Individual }, + { "Factory - Voiced Clear plate", "Bright fast-dense plate with almost stationary late motion", 24, + { .33f, .12f, 15, .95f, 240, 17200, .08f, 1.9f }, .96f, .32f, -3, Kind::Individual }, + { "Factory - Voiced Clear room", "Dry-facing sparse room with a direct early pattern", 25, + { .14f, .19f, 2, .28f, 100, 14800, .91f, .55f }, .57f, .39f, -5, Kind::Individual }, + { "Factory - Voiced Clear random", "Broad randomized clean field with a longer diffuse entrance", 26, + { .69f, .38f, 36, .81f, 130, 11600, .27f, 3.4f }, 1, .33f, -3, Kind::Individual }, + { "Factory - Voiced Radiant hall", "Bright expansive late field with a moderate onset spread", 27, + { .75f, .23f, 32, .84f, 170, 15900, .22f, 4.3f }, 1, .32f, -4, Kind::Individual }, + { "Factory - Voiced Diffuse plate", "Dense soft plate field with little early image and low motion", 28, + { .43f, .53f, 18, .99f, 250, 9200, .06f, 2.1f }, .94f, .34f, -4, Kind::Individual }, + { "Factory - Voiced Compact room", "Small hard room with short decay and strong discrete early image", 29, + { .07f, .11f, 1, .19f, 145, 18000, .98f, .36f }, .42f, .42f, -2, Kind::Individual }, + { "Factory - Voiced Cross chamber", "Warm nested-loop chamber with a restrained stereo image", 30, + { .32f, .61f, 14, .57f, 95, 7400, .63f, 1.3f }, .70f, .37f, -4, Kind::Individual }, + { "Factory - Voiced Ensemble space", "Wide moving ensemble tail with a delayed diffuse attack", 31, + { .65f, .35f, 58, .90f, 220, 12100, .10f, 4.9f }, 1, .29f, -2, Kind::Individual }, + { "Factory - Voiced Reflection field", "Early-dominant low-density reflections with a quiet short tail", 32, + { .40f, .18f, 6, .23f, 70, 16000, .96f, .95f }, .83f, .41f, -8, Kind::Individual }, + { "Factory - Voiced Vault", "Huge dark vault with long spread and strengthened low body", 33, + { .97f, .79f, 74, .93f, 65, 3900, .31f, 10.5f }, 1, .27f, -9, Kind::Individual }, + { "Factory - Voiced Grain hall", "Rough early-digital hall with slow movement and darker bandwidth", 34, + { .70f, .58f, 29, .64f, 180, 7100, .34f, 4.1f }, .87f, .34f, -4, Kind::Individual }, + { "Factory - Voiced Grain plate", "Short gritty bright-digital plate with a sharp entrance", 35, + { .27f, .32f, 9, .73f, 280, 10400, .12f, 1.4f }, .76f, .36f, -2, Kind::Individual }, + { "Factory - Voiced Shaped reflections", "Finite rising reflection envelope with no recursive late tail", 36, + { .29f, .23f, 16, .17f, 210, 13700, .88f, .65f }, .89f, .41f, -2, Kind::Individual }, + { "Factory - Voiced Long nave", "Very long dark dual-voice space with a pronounced onset spread", 37, + { .94f, .70f, 88, .96f, 90, 4700, .39f, 14.0f }, 1, .25f, -5, Kind::Individual }, + { "Factory - Voiced Grand gallery", "Large open gallery with broad early reflections and medium-long decay", 38, + { .76f, .28f, 24, .78f, 140, 13200, .77f, 6.8f }, 1, .31f, -6, Kind::Individual }, + { "Factory - Voiced Aperture chamber", "Filtered nested chamber with strong loop aperture and short warm decay", 39, + { .38f, .62f, 11, .62f, 160, 6800, .58f, 1.7f }, .73f, .36f, -3, Kind::Individual }, + { "Factory - Voiced Aperture hall", "Broad aperture hall with a slower filtered late field", 40, + { .84f, .51f, 49, .88f, 110, 8700, .24f, 5.9f }, 1, .31f, -2, Kind::Individual } +}}; + +inline constexpr Voice collection { "Factory - Voiced collection", + "Loads all 41 authored type profiles; select types to browse their stored settings", 0, + profiles[0].controls, 1, .33f, -14, Kind::Collection }; + +inline constexpr auto voices = [] +{ + std::array result {}; + size_t index = 0; + for (const auto& voice : existingVoices) result[index++] = voice; + for (const auto& voice : profiles) result[index++] = voice; + result[index] = collection; + return result; +}(); + +inline bool refinedProfile(int type) noexcept +{ + return type == 2 || type == 19 || (type >= 21 && type <= 40 && type != 36); +} + +template +inline void storeControls(std::array, Count>& destination, + const std::array& values) noexcept +{ + for (size_t field = 0; field < Count; ++field) destination[field].store(values[field]); +} + +inline bool applyProfile(OpenStudioReverb& processor, const Voice& voice) +{ + for (size_t field = 0; field < voice.controls.size(); ++field) + processor.setBankValue(voice.type, static_cast(field), voice.controls[field]); + switch (voice.type) + { + case 0: case 1: case 3: case 8: case 9: + { + const size_t slot = voice.type == 0 ? 0 : voice.type == 1 ? 1 : voice.type == 3 ? 2 : voice.type == 8 ? 3 : 4; + constexpr std::array modulation { .015f, .32f, .07f, .24f, 0 }; + constexpr std::array bass { .70f, .85f, 1.12f, 1.35f, .55f }; + constexpr std::array cutoff { 16500, 5700, 9600, 3300, 19000 }; + processor.studioEngines[slot].store(1); + processor.studioModulation[slot].store(modulation[slot]); + processor.studioBassRatio[slot].store(bass[slot]); + storeControls(processor.studioToneControls[slot], { 0, cutoff[slot], 0 }); + if (voice.type == 0) processor.roomCharacter.store(1); + break; + } + case 2: + processor.plateEngine.store(1); processor.plateCharacter.store(1); processor.plateModulation.store(.035f); + storeControls(processor.plateToneControls, { 0, 15500, 0 }); + storeControls(processor.plateColourControls, { 3, 160, 1, 1, .14f, 1, 180, -2, 6500, 1.5f }); + break; + case 4: + storeControls(processor.springControls, { 1, 2, 1, .93f, .62f, -2.5f, .12f }); + break; + case 5: + processor.setBankValue(5, 9, .56f); processor.setBankValue(5, 10, 12); + processor.setBankValue(5, 11, 19); processor.setBankValue(5, 12, .32f); + processor.creativeControls[0].store(1); processor.shimmerVoiceEngine.store(1); + processor.shimmerScheduling.store(1); // Opt-in spread pitch-hop scheduling. + break; + case 6: + processor.setBankValue(6, 13, 2); // Reverse finite envelope. + for (size_t i = 1; i < processor.creativeControls.size(); ++i) + processor.creativeControls[i].store(std::array { .12f, .48f, .12f, .55f }[i - 1]); + processor.nonlinearModulation.store(.04f); processor.nonlinearRate.store(.8f); + break; + case 7: + { + // Keep the deterministic generated source embedded. This is a shaped + // version of that source, not a newly claimed acoustic measurement. + auto* edits = new juce::DynamicObject(); + edits->setProperty("end", .82); edits->setProperty("size", .78); + edits->setProperty("directDb", -8.0); edits->setProperty("earlyDb", 2.0); + edits->setProperty("tailDb", -5.0); edits->setProperty("highDecay", .45); + edits->setProperty("eq2Gain", -1.5); edits->setProperty("eq3Gain", -2.0); + return processor.convolutionSpace.edit(juce::var(edits)); + } + case 10: case 11: + { + const size_t slot = static_cast(voice.type - 10); + processor.vintageColour[slot].store(slot == 0 ? 0.0f : 1.0f); + processor.vintageConversion[slot].store(1); processor.vintageTankRate[slot].store(slot == 0 ? 1.0f : 2.0f); + processor.vintageBuildUp[slot].store(slot == 0 ? 105.0f : 18.0f); + processor.vintageInputDiffusion[slot].store(slot == 0 ? .58f : .92f); + processor.vintageModulation[slot].store(slot == 0 ? .52f : .22f); + processor.vintageRate[slot].store(slot == 0 ? .17f : .78f); + processor.vintageBassRatio[slot].store(slot == 0 ? 1.15f : .72f); + processor.vintageBassFrequency[slot].store(slot == 0 ? 390.0f : 730.0f); + break; + } + case 12: case 13: case 14: + { + const size_t slot = static_cast(voice.type - 12); + constexpr std::array, 3> values {{ + { 55, .08f, 0, 8, .38f, .015f, .31f, 1 }, + { 145, .16f, 0, 8, .94f, .21f, .23f, 1 }, + { 235, .57f, 0, 8, .68f, .33f, .48f, 1 } + }}; + storeControls(processor.spatialControls[slot], values[slot]); + processor.spatialLowShelf[slot].store(slot == 1 ? -1.5f : -3.0f); + break; + } + case 15: + storeControls(processor.echoRoomControls[0], { 370, 2, 1, .61f, .29f, 0, .5f, .5f }); + break; + case 16: + storeControls(processor.echoRoomControls[1], { 600, 1, 0, .35f, .1f, 2, .17f, .79f }); + break; + case 17: case 18: case 19: case 20: + { + const size_t slot = static_cast(voice.type - 17); + constexpr std::array, 4> values {{ + { 1.35f, 0, .8f, .30f, .15f, .14f, 0, 1, 10, 0, .82f }, + { .24f, 0, .43f, .72f, .47f, .42f, 0, 1, 10, 0, 1.12f }, + { .5f, 0, .8f, .3f, .82f, .075f, 0, 1, 16, 0, .68f }, + { .5f, 0, .8f, .3f, .36f, .13f, 4, 1, 10, 0, .88f } + }}; + storeControls(processor.ambientControls[slot], values[slot]); + if (voice.type == 20) processor.ambBody.store(.5f); + break; + } + case 21: case 22: case 23: + { + constexpr std::array, 3> values {{ + { 9, .43f, .065f, .14f, .66f, 1.18f, 380 }, + { 3, .045f, .37f, 1.1f, .22f, .62f, 720 }, + { 6.5f, .62f, .22f, .37f, .47f, .86f, 520 } + }}; + storeControls(processor.driftingControls[static_cast(voice.type - 21)], values[static_cast(voice.type - 21)]); + break; + } + case 24: case 25: case 26: + { + constexpr std::array, 3> values {{ + { .94f, 13, .025f, .22f, .72f, 650 }, + { .25f, 4, .035f, .60f, .62f, 410 }, + { .76f, 94, .44f, .43f, .96f, 580 } + }}; + storeControls(processor.clearControls[static_cast(voice.type - 24)], values[static_cast(voice.type - 24)]); + break; + } + default: + { + // Decay, attack, early diffusion, depth/rate, era, bass ratio, + // crossover and aperture are independently authored for all 14. + constexpr std::array, 14> values {{ + { 4.3f, .35f, .81f, .22f, .26f, 2, .85f, 530, .25f }, + { 2.1f, .12f, .99f, .075f, .18f, 2, .67f, 740, .25f }, + { .36f, .025f, .18f, 0, .30f, 2, .55f, 360, .25f }, + { 1.3f, .19f, .47f, .13f, .35f, 2, 1.07f, 410, .25f }, + { 4.9f, .64f, .91f, .74f, .57f, 2, .78f, 620, .25f }, + { .95f, .07f, .20f, .035f, .25f, 2, .65f, 480, .25f }, + { 10.5f, .87f, .94f, .28f, .12f, 2, 1.42f, 290, .25f }, + { 4.1f, .38f, .62f, .37f, .19f, 0, 1.10f, 440, .25f }, + { 1.4f, .06f, .73f, .09f, .66f, 1, .69f, 810, .25f }, + { .65f, .91f, .17f, 0, .30f, 2, 1, 500, .25f }, + { 14, .96f, .96f, .48f, .095f, 2, 1.28f, 310, .25f }, + { 6.8f, .49f, .76f, .31f, .31f, 2, .94f, 510, .25f }, + { 1.7f, .16f, .58f, .12f, .27f, 2, .81f, 630, .86f }, + { 5.9f, .69f, .86f, .42f, .21f, 2, 1.13f, 390, .62f } + }}; + if (voice.type < 27 || voice.type > 40) return false; + const size_t slot = static_cast(voice.type - 27); + storeControls(processor.retroControls[slot], values[slot]); + break; + } + } + return true; +} + +inline const Voice* find(const juce::String& name) noexcept +{ + for (const auto& voice : voices) + if (name.equalsIgnoreCase(voice.name)) + return &voice; + return nullptr; +} + +inline bool isFactory(const juce::String& pluginName, const juce::String& name) noexcept +{ + return pluginName == "OpenStudio Reverb" && find(name) != nullptr; +} + +inline juce::var metadata(const Voice& voice) +{ + auto* value = new juce::DynamicObject(); + value->setProperty("source", "OpenStudio factory"); + value->setProperty("character", voice.character); + value->setProperty("reverbType", voice.type); + value->setProperty("collection", voice.kind == Kind::Collection); + value->setProperty("authoredTypeCount", voice.kind == Kind::Collection ? 41 : 1); + value->setProperty("audioQuality", "not_asserted"); + value->setProperty("loudnessMatch", "not_asserted"); + return value; +} + +inline bool buildState(const juce::String& name, juce::MemoryBlock& state) +{ + const auto* voice = find(name); + if (voice == nullptr) + return false; + + auto processor = std::make_unique(true); + processor->reverbControlMode.store(voice->kind == Kind::Existing ? 0.0f : 1.0f); + processor->selectAlgorithm(voice->type); + if (voice->kind != Kind::Existing) + { + if (voice->kind == Kind::Collection) + { + for (const auto& profile : profiles) + if (! applyProfile(*processor, profile)) return false; + } + else if (! applyProfile(*processor, *voice)) return false; + processor->width.store(voice->width); + processor->wetLevel.store(voice->wet); processor->insertWet.store(voice->wet); + processor->outputTrimDb.store(voice->outputTrimDb); + processor->reverbCharacterMode.store(voice->kind == Kind::Collection || refinedProfile(voice->type) ? 1.0f : 0.0f); + processor->getStateInformation(state); + return !state.isEmpty(); + } + for (size_t field = 0; field < voice->controls.size(); ++field) + processor->setBankValue(voice->type, static_cast(field), voice->controls[field]); + processor->width.store(voice->width); + processor->wetLevel.store(voice->wet); + processor->insertWet.store(voice->wet); + processor->outputTrimDb.store(voice->outputTrimDb); + processor->reverbCharacterMode.store(voice->type == 2 || voice->type == 19 || voice->type >= 21 ? 1.0f : 0.0f); + + switch (voice->type) + { + case 0: + processor->studioModulation[0].store(.02f); + processor->studioBassRatio[0].store(.75f); + break; + case 1: + processor->studioModulation[1].store(.35f); + processor->studioBassRatio[1].store(.80f); + break; + case 2: + processor->plateCharacter.store(0); + processor->plateModulation.store(.08f); + break; + case 4: + processor->springControls[0].store(1); // Dispersive engine. + processor->springControls[1].store(1); // Two springs. + processor->springControls[2].store(.40f); + processor->springControls[3].store(.90f); + processor->springControls[4].store(.68f); + processor->springControls[5].store(-2); + processor->springControls[6].store(.12f); + break; + case 16: + processor->echoRoomControls[1][5].store(1); // Non-square geometry. + processor->echoRoomControls[1][6].store(.22f); + processor->echoRoomControls[1][7].store(.72f); + break; + case 19: + processor->ambientControls[2][4].store(.70f); + processor->ambientControls[2][5].store(.12f); + processor->ambientControls[2][8].store(8); + processor->ambientControls[2][10].store(.85f); + break; + case 38: + processor->retroControls[11][0].store(7.5f); + processor->retroControls[11][1].store(.65f); + processor->retroControls[11][2].store(.85f); + processor->retroControls[11][3].store(.38f); + processor->retroControls[11][4].store(.18f); + processor->retroControls[11][6].store(.80f); + break; + case 5: + processor->setBankValue(5, 9, .38f); + processor->setBankValue(5, 10, 12); + processor->setBankValue(5, 11, 7); + processor->setBankValue(5, 12, .25f); + processor->creativeControls[0].store(0); // Tank-source pitch routing. + break; + default: + break; + } + processor->getStateInformation(state); + return !state.isEmpty(); +} + +// Match the existing editor Mix Lock contract when a factory voice is recalled +// directly through the native API, as well as through the shared toolbar. +inline void preserveLockedMix(const OpenStudioReverb& processor, juce::MemoryBlock& state) +{ + if (processor.mixLock.load() < .5f) + return; + auto tree = juce::ValueTree::readFromData(state.getData(), state.getSize()); + tree.setProperty("wetLevel", processor.wetLevel.load(), nullptr); + tree.setProperty("dryLevel", processor.dryLevel.load(), nullptr); + tree.setProperty("sendMode", processor.sendMode.load(), nullptr); + tree.setProperty("mixLock", processor.mixLock.load(), nullptr); + tree.setProperty("insertWet", processor.insertWet.load(), nullptr); + tree.setProperty("insertDry", processor.insertDry.load(), nullptr); + juce::MemoryOutputStream stream(state, false); + tree.writeToStream(stream); +} +} diff --git a/Source/BuiltInReverbPitchVoice.h b/Source/BuiltInReverbPitchVoice.h index 47825df..f0aaad4 100644 --- a/Source/BuiltInReverbPitchVoice.h +++ b/Source/BuiltInReverbPitchVoice.h @@ -28,7 +28,21 @@ class BuiltInReverbPitchVoice for (auto& channel : input) channel.fill(0); for (auto& channel : output) channel.fill(0); position = 0; + resetting = false; } + void beginReset() + { + stretch.beginReset();resetting=true; + for(auto& channel:input)channel.fill(0); + for(auto& channel:output)channel.fill(0); + position=0; + } + bool resetStep() + { + if(resetting)resetting=!stretch.resetStep(resetBudget); + return !resetting; + } + int resetSamples()const noexcept{return static_cast(stretch.resetStepCount(resetBudget));} std::array process(float left, float right, float ratio) { const auto slot = static_cast(position); @@ -46,7 +60,9 @@ class BuiltInReverbPitchVoice } private: static constexpr int chunkSize = 64; + static constexpr size_t resetBudget = 32; signalsmith::stretch::SignalsmithStretch stretch { 0 }; std::array, 2> input {}, output {}; int position = 0; + bool resetting = false; }; diff --git a/Source/BuiltInReverbReturnLevels.h b/Source/BuiltInReverbReturnLevels.h new file mode 100644 index 0000000..744c6a1 --- /dev/null +++ b/Source/BuiltInReverbReturnLevels.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include + +// Declared, bounded readout trims for the standalone default voices. These are +// fixed design choices, not runtime loudness matching or feedback gains. Native +// multi-material measurements qualify levels; perceived balance needs audition. +namespace BuiltInReverbReturnLevels +{ +inline constexpr std::array decibels { + 14.5f,17.5f,12.5f,15.5f,10,15,30,12,16.5f,9.5f,15,18.5f,10,16.5f,0, + 26.5f,8,26,23.5f,24,21.5f,17.5f,14.5f,17.5f,24,12,19,19.5f,20,9.5f, + 18,22.5f,10,19.5f,16.5f,19.5f,5,17.5f,13,15,18 +}; +inline constexpr std::array linear { + 5.308844442f,7.498942093f,4.216965034f,5.956621435f,3.162277660f, + 5.623413252f,31.622776602f,3.981071706f,6.683439176f,2.985382619f, + 5.623413252f,8.413951416f,3.162277660f,6.683439176f,1.0f, + 21.134890398f,2.511886432f,19.952623150f,14.962356561f,15.848931925f, + 11.885022274f,7.498942093f,5.308844442f,7.498942093f,15.848931925f, + 3.981071706f,8.912509381f,9.440608763f,10.0f,2.985382619f, + 7.943282347f,13.335214322f,3.162277660f,9.440608763f,6.683439176f, + 9.440608763f,1.778279410f,7.498942093f,4.466835922f,5.623413252f, + 7.943282347f +}; +inline float gain(int type, float blend) noexcept +{ + if (blend == 0 || !std::isfinite(blend) || type < 0 || type >= static_cast(linear.size())) + return 1; + const float target = linear[static_cast(type)]; + if (target == 1) return 1; + if (blend >= 1) return target; + return blend > 0 ? 1 + blend * (target - 1) : 1; +} +inline float weighted(float weight, int type, float blend) noexcept +{ + const float amount = gain(type, blend); + return amount == 1 ? weight : weight * amount; // Original is an exact bypass. +} +} diff --git a/Source/BuiltInReverbSparseDiffusion.h b/Source/BuiltInReverbSparseDiffusion.h new file mode 100644 index 0000000..9b0c668 --- /dev/null +++ b/Source/BuiltInReverbSparseDiffusion.h @@ -0,0 +1,97 @@ +#pragma once +#include +#include +#include + +// Short signed FIR readout diffusion, outside every feedback loop. The prepared +// tap positions and unit-energy coefficients are deterministic; they do not +// imply loudness matching or a measured acoustic response. Original bypass is +// exact and does not run or record this optional history. +class BuiltInReverbSparseDiffusion +{ +public: + using Pair = std::array; + static constexpr size_t tapCount = 8; + + void prepare(double sampleRate) + { + const double rate = juce::jmax(8000.0, sampleRate); + constexpr std::array, 2> milliseconds {{ + {{0, 1.1, 2.7, 4.9, 6.7, 8.9, 11.3, 13.9}}, + {{0, 1.3, 3.1, 4.3, 7.3, 9.7, 12.1, 14.7}} + }}; + int capacity = 1; + for (size_t channel = 0; channel < 2; ++channel) + for (size_t tap = 0; tap < tapCount; ++tap) + { + delays[channel][tap] = juce::roundToInt(milliseconds[channel][tap] * rate * .001); + capacity = juce::jmax(capacity, delays[channel][tap] + 1); + } + history.assign(static_cast(capacity), {}); + blend.reset(rate, .05); + processed = 0; + reset(); + } + + void reset() noexcept + { + position = valid = 0; + blend.setCurrentAndTargetValue(0); + initialized = false; + } + + void configure(bool refined) noexcept + { + const float target = refined ? 1.0f : 0.0f; + if (initialized) blend.setTargetValue(target); + else blend.setCurrentAndTargetValue(target); + initialized = true; + } + + Pair process(Pair input) noexcept + { + const float amount = blend.getNextValue(); + if (amount == 0) + { + // Drop stale readout history when a live fade reaches Original. + position = valid = 0; + return input; + } + if (history.empty()) return input; + ++processed; + history[static_cast(position)] = input; + valid = juce::jmin(valid + 1, static_cast(history.size())); + Pair output {}; + constexpr std::array coefficients { + .35355339f, .35355339f, -.35355339f, .35355339f, + -.35355339f, .35355339f, .35355339f, -.35355339f + }; + const int capacity = static_cast(history.size()); + for (size_t channel = 0; channel < 2; ++channel) + { + float diffused = 0; + for (size_t tap = 0; tap < tapCount; ++tap) + { + const int delay = delays[channel][tap]; + if (delay < valid) + diffused += history[static_cast((position + capacity - delay) % capacity)][channel] + * coefficients[tap]; + } + output[channel] = amount == 1 ? diffused : input[channel] + amount * (diffused - input[channel]); + } + if (++position == capacity) position = 0; + return output; + } + + juce::uint64 processedFrames() const noexcept { return processed; } + const auto& tapDelays() const noexcept { return delays; } + int supportSamples() const noexcept { return juce::jmax(0, static_cast(history.size()) - 1); } + +private: + std::vector history; + std::array, 2> delays {}; + juce::SmoothedValue blend; + int position = 0, valid = 0; + juce::uint64 processed = 0; + bool initialized = false; +}; diff --git a/Source/BuiltInSettledReverbTap.h b/Source/BuiltInSettledReverbTap.h new file mode 100644 index 0000000..889d129 --- /dev/null +++ b/Source/BuiltInSettledReverbTap.h @@ -0,0 +1,88 @@ +#pragma once +#include + +// Opt-in removal of a static fractional-delay loss. The original interpolated +// sample remains the moving branch; an integer anchor is changed only while +// its blend is zero. Switching modes or moving a delay fades that fixed anchor +// out, rather than rounding a moving read head into one-sample jumps. +// See Smith, Physical Audio Signal Processing, Delay-Line Interpolation. +class BuiltInSettledReverbTap +{ +public: + void prepare(double rate) noexcept + { + blend.reset(rate, .02); + reset(); + } + + void reset() noexcept + { + blend.setCurrentAndTargetValue(0); + anchor = 1; + initialized = false; + originalBypass = true; + } + + // Callers can skip both the full controller and stationary-tap eligibility + // work on the Original hot path. Observing Original still records history, + // so a later live Refined enable must fade instead of taking a fresh jump. + bool needsProcessing(bool refined) noexcept + { + if (refined || !originalBypass) + return true; + initialized = true; + return false; + } + + template + float process(float original, float delay, bool refined, bool stationary, ReadInteger&& readInteger) noexcept + { + if (!needsProcessing(refined)) + return original; + if (! initialized) + { + anchor = rounded(delay); + blend.setCurrentAndTargetValue(refined && stationary ? 1.0f : 0.0f); + initialized = true; + } + else if (refined && stationary) + { + const float requested = rounded(delay); + if (requested != anchor) + { + blend.setTargetValue(0); + if (blend.getCurrentValue() == 0) + { + anchor = requested; + blend.setTargetValue(1); + } + } + else + blend.setTargetValue(1); + } + else + blend.setTargetValue(0); + + const double preciseAmount = blend.getNextValue(); + originalBypass = preciseAmount == 0; + const float amount = static_cast(preciseAmount); + if (amount == 0) + return original; // Original is an exact floating-point bypass. + const float integer = readInteger(anchor); + if (amount == 1) + return integer; + return original + amount * (integer - original); + } + + float integerBlend() const noexcept { return static_cast(blend.getCurrentValue()); } + float integerAnchor() const noexcept { return anchor; } + +private: + static float rounded(float delay) noexcept { return std::round(juce::jmax(1.0f, delay)); } + // A double accumulator keeps long high-rate fades from accumulating float + // rounding and producing an oversized final step when the target is set. + juce::SmoothedValue blend; + float anchor = 1; + bool initialized = false; + bool originalBypass = true; +}; diff --git a/Source/BuiltInShimmerScheduling.h b/Source/BuiltInShimmerScheduling.h new file mode 100644 index 0000000..544583b --- /dev/null +++ b/Source/BuiltInShimmerScheduling.h @@ -0,0 +1,212 @@ +#pragma once +#include "BuiltInReverbPitchVoice.h" + +// Opt-in split scheduling keeps the pinned 120 ms / 30 ms spectral setup. +// The additional hop is internal to the pitch return, never the dry path. +class BuiltInSpreadReverbPitchVoice +{ +public: + void prepare(double sampleRate, int phaseQuarter) + { + stretch.presetDefault(2, static_cast(sampleRate), true); + stretch.setTransposeFactor(1.0f); + primeSamples = stretch.intervalSamples() * juce::jlimit(0, 3, phaseQuarter) / 4; + reset(); + } + void reset() + { + beginReset(); + while (!resetStep()) {} + while (!primeStep()) {} + } + // Live changes call beginReset once and advance at most one silent sample + // per real sample. Never run the entire role offset in one audio callback. + void beginReset() + { + stretch.beginReset();resetting=true; + for(auto& bank:input)for(auto& channel:bank)channel.fill(0); + for (auto& channel : output) channel.fill(0); + position = 0; + writeBank=0;groupReady=false;groupSilence=0; + primeRemaining = primeSamples; + // Role offsets are reproducible, including after a reset/reprepare. + // No plugin identity, wall clock or random instance phase is used. + } + bool resetStep() + { + if(resetting)resetting=!stretch.resetStep(resetBudget); + return !resetting; + } + int resetSamples()const noexcept{return static_cast(stretch.resetStepCount(resetBudget));} + bool primeStep() + { + if (primeRemaining > 0) { process(0, 0, 1); --primeRemaining; } + return primeRemaining == 0; + } + int primingSamples() const noexcept { return primeSamples; } + int latencySamples() const noexcept + { + return stretch.inputLatency() + stretch.outputLatency() + chunkSize; + } + int extraLatencySamples() const noexcept { return stretch.intervalSamples(); } + std::array process(float left, float right, float ratio) + { + const auto slot = static_cast(position); + const std::array result{output[0][slot], output[1][slot]}; + input[writeBank][0][slot] = left; input[writeBank][1][slot] = right; + position=(position+1)%chunkSize; + if(position==0) + { + writeBank=1-writeBank;groupReady=true; + const auto& pending=input[1-writeBank]; + const float* completeGroup[]{pending[0].data(),pending[1].data()}; + stretch.setTransposeFactor(juce::jlimit(.25f,4.0f,ratio)); + groupSilence=stretch.beginProcessGroup(completeGroup,chunkSize)?1:0; + } + if(groupReady&&position%workQuantum==0) + { + // Keep the existing 64-sample return delay, but distribute the + // library's spectral stages over eight-sample work quanta. At + // 192 kHz the former adapter regrouped split stages into bursts. + const auto offset=static_cast(position); + const auto& pending=input[1-writeBank]; + const float* in[]{pending[0].data()+offset,pending[1].data()+offset}; + float* out[]{output[0].data()+offset, output[1].data()+offset}; + stretch.process(in, workQuantum, out, workQuantum,groupSilence); + } + return result; + } +private: + static constexpr int chunkSize = 64; + static constexpr int workQuantum = 8; + static constexpr size_t resetBudget = 32; + signalsmith::stretch::SignalsmithStretch stretch{0}; + std::array,2>,2> input{}; + std::array, 2> output{}; + int position = 0, primeSamples = 0, primeRemaining = 0; + bool resetting = false; + size_t writeBank=0; + bool groupReady=false; + int groupSilence=0; +}; + +// Both banks are prepared off the callback. A live change clears the target +// voices one at a time, warms their history, then fades for 20 ms. The inactive +// bank does no spectral work once the fade ends. Original takes an exact bypass. +class BuiltInShimmerScheduling +{ +public: + void prepare(double sampleRate, int warmupSamples, int primeSamples = 0, int resetSamples = 0) + { + fade.reset(sampleRate, .02); + warmup = juce::jmax(1, warmupSamples); + priming = juce::jmax(0, primeSamples); + clearingSamples = juce::jmax(0, resetSamples); + reset(); + } + void reset() noexcept + { + initialized = false; requestedSpread = stableSpread = targetSpread = false; + phase = idle; remaining = resetVoice = resetCountdown = 0; + blend = 0; fade.setCurrentAndTargetValue(0); + } + void configure(bool spread, bool spreadHistoryReady = true) noexcept + { + requestedSpread = spread; + if (!initialized) + { + const bool startSpread = spread && spreadHistoryReady; + initialized = true; stableSpread = targetSpread = startSpread; + blend = startSpread ? 1.0f : 0.0f; + fade.setCurrentAndTargetValue(startSpread ? 1.0 : 0.0); + } + else if (phase == fading) + fade.setTargetValue(spread ? 1.0 : 0.0); + } + // Returns one target-bank voice to reset, at most once every 64 samples. + // This moves existing-library reset/prime work away from one large burst. + int advance() noexcept + { + if (phase != fading && requestedSpread == stableSpread) + { + phase = idle; blend = stableSpread ? 1.0f : 0.0f; + return -1; + } + if (phase == idle) + { + targetSpread = requestedSpread; + phase = clearing; resetVoice = resetCountdown = 0; + } + if (phase == clearing) + { + if (resetCountdown > 0) { --resetCountdown; return -1; } + const int voice = resetVoice++; + resetCountdown = 63; + if (resetVoice == 4) + { + phase = clearingSamples>0?clearingBank:targetSpread && priming > 0 ? primingBank : warming; + remaining = phase==clearingBank?clearingSamples:phase == primingBank ? priming : warmup; + } + return voice; + } + if(phase==clearingBank) + { + if(--remaining<=0){phase=targetSpread&&priming>0?primingBank:warming;remaining=phase==primingBank?priming:warmup;} + return -1; + } + if (phase == primingBank) + { + if (--remaining <= 0) { phase = warming; remaining = warmup; } + return -1; + } + if (phase == warming) + { + if (--remaining <= 0) + { + phase = fading; + fade.setCurrentAndTargetValue(stableSpread ? 1.0 : 0.0); + fade.setTargetValue(requestedSpread ? 1.0 : 0.0); + } + else return -1; + } + if (phase == fading) + { + blend = static_cast(fade.getNextValue()); + if (!fade.isSmoothing()) + { + stableSpread = requestedSpread; + targetSpread = stableSpread; + phase = idle; blend = stableSpread ? 1.0f : 0.0f; + } + } + return -1; + } + bool resetSpreadBank() const noexcept { return targetSpread; } + bool clearingTargetBank()const noexcept{return phase==clearing||phase==clearingBank;} + bool primeSpreadBank() const noexcept { return phase == primingBank; } + bool processOriginal() const noexcept + { + return !stableSpread || phase == fading || (phase == warming && !targetSpread); + } + bool processSpread() const noexcept + { + return stableSpread || phase == fading || (phase == warming && targetSpread); + } + bool isSettled() const noexcept { return phase == idle && requestedSpread == stableSpread; } + bool isSpread() const noexcept { return stableSpread; } + std::array mix(const std::array& original, + const std::array& spread) const noexcept + { + if (blend == 0) return original; + if (blend == 1) return spread; + return {original[0] * (1 - blend) + spread[0] * blend, + original[1] * (1 - blend) + spread[1] * blend}; + } +private: + enum Phase {idle, clearing, clearingBank, primingBank, warming, fading}; + Phase phase = idle; + juce::SmoothedValue fade; + int warmup = 1, priming = 0, clearingSamples=0, remaining = 0, resetVoice = 0, resetCountdown = 0; + float blend = 0; + bool initialized = false, requestedSpread = false, stableSpread = false, targetSpread = false; +}; diff --git a/Source/BuiltInSpatialReverb.h b/Source/BuiltInSpatialReverb.h index 89f5726..71a4aca 100644 --- a/Source/BuiltInSpatialReverb.h +++ b/Source/BuiltInSpatialReverb.h @@ -1,5 +1,6 @@ #pragma once #include +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbSpillover.h" // Three original spaces: integral-tap room/hall FDNs and an allpass echo loop. @@ -12,6 +13,7 @@ class BuiltInSpatialReverb float lowCut=20,highCut=20000,width=1,density=1,modulation=.25f,rate=.4f,amount=1; bool freeze=false, infiniteInput=false; float lowShelfDb=0; + bool correctedControls=false; }; static constexpr std::array beats{.0625f,.083333333f,.125f,.166666667f,.25f,.333333333f,.5f,.75f,1,1.5f,2,3,4,8,16}; static float capacitySeconds(float choice) noexcept {return std::array{6,24,96}[static_cast(juce::jlimit(0,2,juce::roundToInt(choice)))];} @@ -75,6 +77,14 @@ class BuiltInSpatialReverb } initialized=true; } + float modulationDepth(float requested, float base, const Delay& line) const noexcept + { + if (!settings.correctedControls) return requested; + // Keep moving taps away from both interpolation limits, including + // transitions of the shortest Contour/Diffuse delay stages. + const float room = juce::jmax(0.0f, juce::jmin(base - 1.0f, static_cast(line.data.size() - 2) - base)); + return juce::jmin(requested, room * .45f); + } std::array process(float left,float right,bool active) { ++frames;const float currentSize=sizeValue.getNextValue();const float fb=feedback.getNextValue(),damp=damping.getNextValue(),diffuse=diffusion.getNextValue(),motion=depth.getNextValue(),frequency=speed.getNextValue(); @@ -101,7 +111,7 @@ class BuiltInSpatialReverb for(size_t stage=0;stage<6;++stage) { const float length=times[stage]*(.2f+currentSize*3)*static_cast(fs)*.001f; - const float mod=motion*static_cast(std::sin(juce::MathConstants::twoPi*(phase+static_cast(stage+ch*6)*.137))); + const float mod=modulationDepth(motion,length,diffusers[ch][stage])*static_cast(std::sin(juce::MathConstants::twoPi*(phase+static_cast(stage+ch*6)*.137))); auto& line=diffusers[ch][stage];const float output=line.read(length+mod)-diffuse*signal;line.write(signal+diffuse*output);signal=output; } space[ch]=signal; @@ -115,7 +125,7 @@ class BuiltInSpatialReverb for(int i=0;i(i);const float time=lengths[index].getNextValue(); - const float mod=motion*(1-held)*static_cast(std::sin(juce::MathConstants::twoPi*(phase+static_cast(i)*.173))); + const float mod=modulationDepth(motion,time,lines[index])*(1-held)*static_cast(std::sin(juce::MathConstants::twoPi*(phase+static_cast(i)*.173))); const float value=lines[index].read((held>=.99999f?std::round(time):time)+mod); dampState[index]+=(damp+held*(1-damp))*(value-dampState[index]);const float loss=losses[index].getNextValue();tank[index]=dampState[index]*(loss+held*(1-loss)); const float early=lines[index].read(time*(mode==0?.27f:.41f)); @@ -183,14 +193,16 @@ class BuiltInSpatialReverb else if(running[i]&&engines[i].settings.freeze){auto retiring=engines[i].settings;retiring.freeze=false;engines[i].configure(retiring);} } } - std::array process(float left,float right) + std::array process(float left,float right,float returnBlend=0) { std::array result{};if(!prepared)return result; left=std::isfinite(left)?juce::jlimit(-16.0f,16.0f,left):0;right=std::isfinite(right)?juce::jlimit(-16.0f,16.0f,right):0; for(size_t i=0;i0);result[0]+=output[0]*wetWeight;result[1]+=output[1]*wetWeight;result[2]+=weight; + running[i]=true;const auto output=engines[i].process(left,right,weights[i].getTargetValue()>0); + const float returnWeight=BuiltInReverbReturnLevels::weighted(wetWeight,12+static_cast(i),returnBlend); + result[0]+=output[0]*returnWeight;result[1]+=output[1]*returnWeight;result[2]+=weight; } return result; } diff --git a/Source/BuiltInSpringReverb.h b/Source/BuiltInSpringReverb.h index e69184b..a3b6fba 100644 --- a/Source/BuiltInSpringReverb.h +++ b/Source/BuiltInSpringReverb.h @@ -1,7 +1,9 @@ #pragma once #include #include "BuiltInReverbSpillover.h" +#include "BuiltInInfiniteHoldBudget.h" #include +#include #include // Original dispersive bidirectional delay model. Its coefficients are not a @@ -14,6 +16,11 @@ class BuiltInSpringReverb float decay=3, damping=.5f, predelay=0, lowCut=20, highCut=20000, width=1; float count=2, dwell=0, dispersion=.6f, tension=.5f, bass=0, motion=.2f; bool hold=false, acceptsInput=false; + bool operator==(const Settings& other)const noexcept + { + return std::tie(decay,damping,predelay,lowCut,highCut,width,count,dwell,dispersion,tension,bass,motion,hold,acceptsInput) + ==std::tie(other.decay,other.damping,other.predelay,other.lowCut,other.highCut,other.width,other.count,other.dwell,other.dispersion,other.tension,other.bass,other.motion,other.hold,other.acceptsInput); + } }; private: struct Line @@ -64,6 +71,7 @@ class BuiltInSpringReverb bool enabled=false, awake=false, initialized=false; float bassPole=0, dcPole=0; BuiltInReverbRetirement<1> retirement; + BuiltInInfiniteHoldBudget infiniteWriteBudget,infiniteReadoutBudget; public: void prepare(double sampleRate) @@ -85,6 +93,7 @@ class BuiltInSpringReverb for(auto& channel:springs)for(auto& spring:channel)spring.reset(); for(auto& line:pre)line.reset(); inputLow={};wetLow={};wetHigh={};dcInput={};dcOutput={};phase=0;initialized=false;awake=enabled=false;weight.setCurrentAndTargetValue(0);retirement.reset({weight}); + infiniteWriteBudget.reset();infiniteReadoutBudget.reset(); } double retiringTailSeconds()const noexcept{return retirement.retiringTailSeconds();} bool isSuspended() const noexcept { return !awake; } @@ -98,11 +107,13 @@ class BuiltInSpringReverb next.tension=settings.tension;next.dispersion=settings.dispersion;next.count=settings.count; } retirement.configure(0,active,retainTails,juce::jlimit(.8,10.0,static_cast(next.decay))*2+2+next.predelay*.001); + const bool targetsUnchanged=initialized&&enabled==active&&settings==next; const bool shapeChanged=!initialized||settings.tension!=next.tension||settings.dispersion!=next.dispersion||settings.decay!=next.decay||settings.hold!=next.hold; enabled=active;settings=next; if(active&&!awake){awake=true;initialized=false;} weight.setTargetValue(active?1.0f:0.0f); if(!awake)return; + if(targetsUnchanged)return; const auto set=[&](auto& smoother,float value){if(initialized)smoother.setTargetValue(value);else smoother.setCurrentAndTargetValue(value);}; const int count=juce::jlimit(1,3,juce::roundToInt(next.count)); const int colour=juce::jlimit(0,3,juce::roundToInt(next.dwell)); @@ -141,7 +152,7 @@ class BuiltInSpringReverb } initialized=true; } - std::array process(float left,float right) noexcept + std::array process(float left,float right,float returnGain=1) noexcept { const float mix=weight.getNextValue(),wetMix=retirement.next(0);if(!awake)return {}; if(!enabled&&mix<=0&&wetMix<=0){reset();return {};} @@ -150,6 +161,8 @@ class BuiltInSpringReverb const float useLow=lowEnable.getNextValue(),useHigh=highEnable.getNextValue(),depth=motion.getNextValue(),excitation=inputGate.getNextValue(); phase+=juce::MathConstants::twoPi*.37/fs;if(phase>=juce::MathConstants::twoPi)phase-=juce::MathConstants::twoPi; const std::array input{left,right};std::array wet{}; + std::array writes{}; + const bool infinite=settings.hold&&settings.acceptsInput; for(size_t ch=0;ch<2;++ch) { float source=enabled&&std::isfinite(input[ch])?juce::jlimit(-8.0f,8.0f,input[ch]):0.0f; @@ -161,7 +174,7 @@ class BuiltInSpringReverb for(size_t index=0;index<3;++index) { auto& spring=springs[ch][index];const float length=spring.length.getNextValue(),loss=spring.loss.getNextValue(); - const float offset=depth*static_cast(fs*.00015*std::sin(phase+static_cast(index)*1.7+static_cast(ch)*.8)); + const float offset=depth==0?0:depth*static_cast(fs*.00015*std::sin(phase+static_cast(index)*1.7+static_cast(ch)*.8)); float outward=spring.forward.read(length+offset),inward=spring.backward.read(length-offset); for(size_t stage=0;stage +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbSpillover.h" #include +#include #include // Original standalone studio spaces. Orthogonal feedback and delay-proportional @@ -17,6 +20,12 @@ class BuiltInStudioReverb float width = 1, modulation = .15f, bassRatio = 1; bool freeze = false, infiniteInput = false; int decayFilter=0; float decayCutoff=8000; bool outputCutOff=false; + bool correctedControls = false; + bool operator==(const Settings& other)const noexcept + { + return std::tie(decay,size,damping,diffusion,preDelay,lowCut,highCut,early,width,modulation,bassRatio,freeze,infiniteInput,decayFilter,decayCutoff,outputCutOff,correctedControls) + ==std::tie(other.decay,other.size,other.damping,other.diffusion,other.preDelay,other.lowCut,other.highCut,other.early,other.width,other.modulation,other.bassRatio,other.freeze,other.infiniteInput,other.decayFilter,other.decayCutoff,other.outputCutOff,other.correctedControls); + } }; private: struct Delay @@ -60,6 +69,7 @@ class BuiltInStudioReverb std::array, 4> diffuserGain; juce::SmoothedValue preSamples, inputGain, scatter, dampPole, lowPole, highPole, earlyGain, width, depth, size; std::array base {}, bassState {}, dampState {}; + std::array reflectionFalloff {}; std::array lowState {}, highState {}; std::array diffuserLength {}; Settings settings; @@ -67,9 +77,12 @@ class BuiltInStudioReverb double phase = 0; int kind = 0, count = 8, stages = 1, drain = 0; bool initialized = false; + bool configuredActive = false; void prepare(double sampleRate, int type) { rate = static_cast(sampleRate); kind = type; + for (size_t tap = 0; tap < reflectionFalloff.size(); ++tap) + reflectionFalloff[tap] = std::exp(-.21f * static_cast(tap)); count = kind == 1 || kind == 3 ? 16 : 8; stages = kind == 5 ? 2 : kind == 0 || kind == 4 ? 1 : (kind == 2 ? 3 : 4); constexpr std::array, 6> milliseconds {{ @@ -112,7 +125,10 @@ class BuiltInStudioReverb } void configure(const Settings& values, bool active) { + const auto previous=settings; if (active) settings = values; + if(initialized&&configuredActive==active&&settings==previous)return; + configuredActive=active; const bool hold = active && settings.freeze; const auto set = [this](auto& smoother, float target) { @@ -134,7 +150,7 @@ class BuiltInStudioReverb const float cutoff = kind == 5 ? 20000 * std::pow(1.0f/16.0f,settings.damping) : 18000 * std::pow(1.0f/36.0f,settings.damping); set(dampPole,hold||settings.decayFilter==1 ? 0 : settings.decayFilter==2 ? std::exp(-juce::MathConstants::twoPi*juce::jmin(rate*.45f,settings.decayCutoff)/rate) - : settings.damping*std::exp(-juce::MathConstants::twoPi*cutoff/rate)); + : BuiltInReverbDamping::legacyTone(settings.damping, cutoff, rate, settings.correctedControls)); set(lowPole,std::exp(-juce::MathConstants::twoPi*settings.lowCut/rate)); set(highPole,settings.outputCutOff?0:std::exp(-juce::MathConstants::twoPi*juce::jmin(rate*.45f,settings.highCut)/rate)); set(earlyGain,settings.early); set(width,settings.width); set(depth,hold ? 0 : settings.modulation*rate*.00035f); @@ -205,7 +221,7 @@ class BuiltInStudioReverb for (int tap = 0; tap < 8; ++tap) { const float time = earlyMs[static_cast(kind)][static_cast(tap)] * .001f * rate * scale; - const float amplitude = earlyLevel*(kind == 4 ? .28f : .14f)*std::exp(-.21f*static_cast(tap)); + const float amplitude = earlyLevel*(kind == 4 ? .28f : .14f)*reflectionFalloff[static_cast(tap)]; output[0] += early[static_cast(tap%2)].read(time+1)*amplitude; output[1] += early[static_cast((tap+1)%2)].read(time*1.113f+1)*amplitude*(tap%3==0 ? -.8f : 1.0f); } @@ -226,7 +242,7 @@ class BuiltInStudioReverb for (int i = 0; i < count; ++i) { const auto slot = static_cast(i); - const float excursion = modulation*static_cast(std::sin(phase*(1.0+.07*i)+i*.93)); + const float excursion = modulation==0?0:modulation*static_cast(std::sin(phase*(1.0+.07*i)+i*.93)); const float value = lines[slot].read(length[slot].getNextValue()+excursion); output[0] += value*normalization*(i%2==0 ? lateLevel : -lateLevel); output[1] += value*normalization*(i%4<2 ? lateLevel : -lateLevel); @@ -296,15 +312,27 @@ class BuiltInStudioReverb for(size_t i=0;i(engines[i].drain)/engines[i].rate); return seconds; } - std::array process(float left,float right) + bool isDormant() const noexcept + { + for (const auto& engine : engines) if (engine.drain > 0) return false; + return true; + } + std::array process(float left,float right,float returnBlend=0) { if (!prepared) return {}; const auto safe=[](float value) { return std::isfinite(value) ? juce::jlimit(-16.0f,16.0f,value) : 0.0f; }; std::array output {}; + constexpr std::array returnTypes {0,1,3,8,9,0}; for (size_t i = 0; i < engines.size(); ++i) { - const auto wet=engines[i].process(safe(left),safe(right),weights[i].getTargetValue()>0); - const float weight=weights[i].getNextValue(),wetWeight=spillWeights.next(i); output[0]+=wet[0]*wetWeight; output[1]+=wet[1]*wetWeight; output[2]+=weight; + const bool active = weights[i].getTargetValue() > 0; + const float weight = weights[i].getNextValue(), wetWeight = spillWeights.next(i); + // Once its outgoing return is silent, invalidate history without + // clearing the prepared delay buffers on the audio thread. + if (!active && wetWeight <= 0 && engines[i].drain > 0) engines[i].reset(); + const auto wet = engines[i].process(safe(left), safe(right), active); + const float returnWeight=BuiltInReverbReturnLevels::weighted(wetWeight,returnTypes[i],returnBlend); + output[0]+=wet[0]*returnWeight; output[1]+=wet[1]*returnWeight; output[2]+=weight; } return output; } diff --git a/Source/BuiltInUtilityEffects.h b/Source/BuiltInUtilityEffects.h index 6d56b66..6eb1d65 100644 --- a/Source/BuiltInUtilityEffects.h +++ b/Source/BuiltInUtilityEffects.h @@ -1,4 +1,5 @@ #pragma once +#include "BuiltInFilterTail.h" #include "BuiltInEffects.h" #include "BuiltInPreampTone.h" #include "BuiltInAverageMeter.h" @@ -81,6 +82,7 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect controls.push_back({ id.toRawUTF8(), id.toRawUTF8(), -1440, 1440, 0, "degrees" }); } } + if (kind == Kind::Preamp) controls.push_back({ "signalPath", "Signal path", 0, 1, 1, "" }); for (size_t i = 0; i < controls.size(); ++i) values[i].store(controls[i].initial); } const juce::String getName() const override @@ -88,9 +90,49 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect return kind == Kind::Preamp ? "OpenStudio Preamp" : kind == Kind::GraphicEQ ? "OpenStudio Graphic EQ" : "OpenStudio Gain Phase"; } const Kind kind; - double getTailLengthSeconds() const override { return kind == Kind::Preamp && values[12].load() >= .5f ? 1.0 : 0.0; } + double getTailLengthSeconds() const override + { + const double fs = juce::jmax(1.0, visualRate.load()); + double tail = 0; + if (kind == Kind::Preamp) return values[12].load() >= .5f ? 1.0 : 0.0; + if (kind == Kind::GainPhase) + { + for (size_t ch = 0; ch < 2; ++ch) + { + double channel = (juce::jlimit(0.0f, 2048.0f, values[3 + ch].load()) + 4.0) / fs; + if (values[8 + ch].load() >= .5f) + channel += (juce::jlimit(0, 3, juce::roundToInt(values[12 + ch].load())) + 1) + * BuiltInFilterTail::pole(std::abs(BuiltInPhaseAlignment::coefficient(fs, values[10 + ch].load())), fs); + tail = juce::jmax(tail, channel); + } + if (values[14].load() >= .5f) + tail += static_cast(BuiltInLinearPhaseEQ::tapCount(1) + BuiltInLinearPhaseEQ::quantum) / fs; + } + else + { + const auto settings = graphicSettings(); + // Both banks can carry state during the mode fade. + for (int bank = 0; bank < 2; ++bank) + { + double bankTail = 0; + for (size_t i = 0; i < (bank == 0 ? 10u : BuiltInGraphicEQ::bandCount); ++i) + { + const float gain = bank == 0 ? values[i].load() : settings.gains[i]; + if (gain == 0) continue; + bankTail += BuiltInFilterTail::biquad(BuiltInGraphicEQ::peak(fs, + bank == 0 ? 31.25 * std::exp2(static_cast(i)) : BuiltInGraphicEQ::frequencies[i], + gain, bank == 0 ? std::sqrt(2.0) : BuiltInGraphicEQ::q()), fs); + } + tail = juce::jmax(tail, bankTail); + } + tail += BuiltInFilterTail::biquad(BuiltInGraphicEQ::cut(fs, settings.highPass, true, settings.highPassEnabled >= .5f), fs) + + BuiltInFilterTail::biquad(BuiltInGraphicEQ::cut(fs, settings.lowPass, false, settings.lowPassEnabled >= .5f), fs); + } + return BuiltInFilterTail::retain(retainedTail, tail > 0 ? tail + .02 : 0); + } std::vector controls; - std::array, 128> values {}; + static constexpr size_t controlCapacity = 128; + std::array, controlCapacity> values {}; std::atomic correlation { 0.0f }; std::unique_ptr alignmentCapture; bool setControl(const juce::String& id, float value, bool deferConfiguration = false) @@ -144,7 +186,10 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect refreshSpectralConfiguration(true); if (kind == Kind::GraphicEQ) { graphic.prepare(sampleRate); graphicCapture->prepare(sampleRate); } if (kind == Kind::GainPhase) phase.prepare(sampleRate); + maximumBlock = juce::jmax(1, maximumBlock); + preparedMaximumBlock = maximumBlock; scratch.setSize(2, maximumBlock); + preampDry.setSize(2, maximumBlock); delays.setSize(2, 4096); if (kind == Kind::Preamp) { @@ -159,6 +204,8 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect else oversampler = std::make_unique>(2, 2, juce::dsp::Oversampling::filterHalfBandPolyphaseIIR, true, true); oversampler->initProcessing(static_cast(maximumBlock)); setLatencySamples(static_cast(oversampler->getLatencyInSamples())); + preampBypass.reset(sampleRate, .010); + preampBypass.setCurrentAndTargetValue(values[3].load()); tone.prepare(sampleRate * preampOversamplingFactor); for (auto& stage : originalPreamp) stage.prepare(sampleRate * preampOversamplingFactor, BuiltInOriginalColour::Preamp); averageMeter.prepare(sampleRate); @@ -174,7 +221,10 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect void reset() override { if(alignmentCapture)alignmentCapture->abort(); - delays.clear(); scratch.clear(); position = 0; updateCounter = 0; + retainedTail.store(0); + tailControls.fill(std::numeric_limits::quiet_NaN()); + delays.clear(); scratch.clear(); preampDry.clear(); position = 0; updateCounter = 0; + preampBypass.setCurrentAndTargetValue(values[3].load()); filterState = {}; dcInput = {}; dcOutput = {}; powers = {}; for (size_t ch = 0; ch < 2; ++ch) { @@ -194,7 +244,18 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect juce::ScopedNoDenormals noDenormals; const int channels = juce::jmin(2, buffer.getNumChannels()); const int samples = buffer.getNumSamples(); - if (channels == 0 || samples == 0) return; + if (channels == 0 || samples == 0 || !prepared) return; + if (kind == Kind::Preamp && samples > preparedMaximumBlock) + { + std::array pointers {}; + for (int offset = 0; offset < samples; offset += preparedMaximumBlock) + { + for (int ch = 0; ch < channels; ++ch) pointers[static_cast(ch)] = buffer.getWritePointer(ch, offset); + juce::AudioBuffer slice(pointers.data(), channels, juce::jmin(preparedMaximumBlock, samples - offset)); + processBlock(slice, midi); + } + return; + } if(alignmentCapture&&alignmentCapture->state.load(std::memory_order_relaxed)==1) { const auto playbackPosition=getPlayHead()?getPlayHead()->getPosition():juce::Optional(); @@ -206,6 +267,19 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect if (spectral) spectral->request(spectralSnapshot()); if (kind == Kind::Preamp) { + const bool corrected = values[15].load() >= .5f; + preampBypass.setTargetValue(values[3].load()); + if (corrected) for (int i = 0; i < samples; ++i) + { + const int read = (position - getLatencySamples() + delays.getNumSamples()) % delays.getNumSamples(); + for (int ch = 0; ch < channels; ++ch) + { + const float input = buffer.getSample(ch, i); + delays.setSample(ch, position, std::isfinite(input) ? input : 0.0f); + preampDry.setSample(ch, i, delays.getSample(ch, read)); + } + if (++position == delays.getNumSamples()) position = 0; + } averageMeter.measure(buffer,false); float inputPeak=0,drivenPeak=0; for(int ch=0;chprocessSamplesDown(working); - for (int ch = 0; ch < channels; ++ch) buffer.copyFrom(ch, 0, scratch, ch, 0, samples); + for (int i = 0; i < samples; ++i) + { + const float amount = preampBypass.getNextValue(); + for (int ch = 0; ch < channels; ++ch) + { + const float wet = scratch.getSample(ch, i); + buffer.setSample(ch, i, corrected ? (1 - amount) * wet + amount * preampDry.getSample(ch, i) : wet); + } + } inputDb.store(juce::Decibels::gainToDecibels(inputPeak,-100.0f)); outputDb.store(juce::Decibels::gainToDecibels(buffer.getMagnitude(0,samples),-100.0f)); drivenReferenceDb.store(juce::Decibels::gainToDecibels(drivenPeak,-100.0f)); averageMeter.measure(buffer,true); return; } + bool tailChanged = false; + for (size_t i = 0; i < controls.size(); ++i) + { + const float value = values[i].load(); + tailChanged = tailChanged || value != tailControls[i]; tailControls[i] = value; + } + if (tailChanged) getTailLengthSeconds(); if (kind == Kind::GraphicEQ) { graphic.configure(graphicSettings()); graphicCapture->beginBlock(samples); } std::array desiredDelay {}; if (kind == Kind::GainPhase) @@ -368,9 +458,11 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect { const auto state = juce::ValueTree::readFromData(data, static_cast(size)); if (!state.isValid() || state.getType().toString() != getName().removeCharacters(" ")) return; - for (const auto& control : controls) setControl(control.id, static_cast(state.getProperty(control.id, control.initial)), true); + const float previousSignalPath = values[15].load(); + for (const auto& control : controls) setControl(control.id, static_cast(state.getProperty(control.id, juce::String(control.id) == "signalPath" ? 0.0f : control.initial)), true); refreshSpectralConfiguration(true); refreshCharacterConfiguration(); + if (kind == Kind::Preamp && prepared && previousSignalPath != values[15].load()) prepareToPlay(rate, preparedMaximumBlock); } BuiltInGraphicEQ::Settings graphicSettings() const { @@ -434,12 +526,15 @@ class OpenStudioUtilityEffect final : public OpenStudioBuiltInEffect BuiltInGraphicEQ graphic; std::unique_ptr graphicCapture; std::atomic visualRate { 44100 }; + mutable std::atomic retainedTail {0}; std::atomic inputDb { -100 }, outputDb { -100 }; BuiltInPreampTone tone; BuiltInAverageMeter averageMeter; std::atomic drivenReferenceDb{-100}; double rate = 44100.0; - juce::AudioBuffer scratch, delays; + std::array tailControls {}; + juce::AudioBuffer scratch, delays, preampDry; + juce::SmoothedValue preampBypass; std::array, 64> smooth; std::array originalPreamp; int position = 0; diff --git a/Source/BuiltInVintageReverb.h b/Source/BuiltInVintageReverb.h index 98eca00..5d2128a 100644 --- a/Source/BuiltInVintageReverb.h +++ b/Source/BuiltInVintageReverb.h @@ -1,5 +1,6 @@ #pragma once #include +#include "BuiltInReverbReturnLevels.h" #include "BuiltInReverbSpillover.h" #include "BuiltInVintageConverter.h" #include @@ -308,6 +309,8 @@ class BuiltInVintageReverb for(size_t i=0;i<3;++i) { const bool running=enabled&&active&&static_cast(i)==selected; + const float weight=spill.next(i);weights[i].getNextValue(); + if(!running&&weight<=0&&banks[i].drain>0)banks[i].reset(); std::array wet{}; if(running||banks[i].drain>0) { @@ -316,8 +319,11 @@ class BuiltInVintageReverb if(i==0)wet=banks[i].process(left,right,running); else wet=converters[i-1].processThrough({left,right},[&](const auto& sampled) noexcept {return banks[i].process(sampled[0],sampled[1],running);}); } - else processing[i]=false; - const float weight=spill.next(i);weights[i].getNextValue(); + else if(processing[i]) + { + if(i>0)converters[i-1].reset(); + processing[i]=false; + } for(size_t ch=0;ch<2;++ch)output[ch]+=wet[ch]*weight; drain=juce::jmax(drain,static_cast(std::ceil(static_cast(banks[i].drain)*rate/banks[i].rate))); frames+=banks[i].frames; @@ -368,21 +374,36 @@ class BuiltInVintageReverb juce::uint64 processingFrames(size_t slot) const noexcept { return engines[slot] ? engines[slot]->frames : 0; } juce::uint64 processingFrames(size_t slot,size_t bank) const noexcept { return engines[slot] ? engines[slot]->banks[bank].frames : 0; } double processingRate(size_t slot,size_t bank) const noexcept { return engines[slot] ? engines[slot]->banks[bank].rate : 0; } + int runningTankCount(size_t slot) const noexcept + { + int count = 0; + if (engines[slot]) for (const auto& bank : engines[slot]->banks) if (bank.drain > 0) ++count; + return count; + } + bool isDormant() const noexcept + { + for (const auto& engine : engines) if (engine && engine->drain > 0) return false; + return true; + } double retiringTailSeconds() const noexcept { double seconds=0; for(size_t i=0;i(engines[i]->drain)/engines[i]->rate); return seconds; } - std::array process(float left, float right) + std::array process(float left, float right, float returnBlend = 0) { if (!prepared) return {}; const auto safe = [](float value) { return std::isfinite(value) ? juce::jlimit(-16.0f, 16.0f, value) : 0.0f; }; std::array output {}; for (size_t i = 0; i < engines.size(); ++i) { - const auto wet = engines[i]->process(safe(left), safe(right), weights[i].getTargetValue() > 0); - const float weight = weights[i].getNextValue(),wetWeight=spillWeights.next(i); output[0] += wet[0] * wetWeight; output[1] += wet[1] * wetWeight; output[2] += weight; + const bool active = weights[i].getTargetValue() > 0; + const float weight = weights[i].getNextValue(),wetWeight=spillWeights.next(i); + if (!active && wetWeight <= 0 && engines[i]->drain > 0) engines[i]->reset(); + const auto wet = engines[i]->process(safe(left), safe(right), active); + const float returnWeight = BuiltInReverbReturnLevels::weighted(wetWeight, 10 + static_cast(i), returnBlend); + output[0] += wet[0] * returnWeight; output[1] += wet[1] * returnWeight; output[2] += weight; } return output; } diff --git a/Source/CLAPAutomationRegression.inc b/Source/CLAPAutomationRegression.inc index c4d12ff..2e0afdb 100644 --- a/Source/CLAPAutomationRegression.inc +++ b/Source/CLAPAutomationRegression.inc @@ -126,6 +126,14 @@ struct CLAPAutomationFixture juce::var runOpenStudioCLAPAutomationRegression() { + const auto setupFailure = [](const char* reason) + { + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "CLAP automation protocol fixture"); + result->setProperty("pass", false); + result->setProperty("setupFailure", reason); + return juce::var(result); + }; // Timer dispatch depends on JUCE's message queue as well as its clock. // Headless startup has not entered the normal app dispatch loop yet. const auto dispatchUntil = [](const std::function& ready, int milliseconds) @@ -178,6 +186,10 @@ juce::var runOpenStudioCLAPAutomationRegression() if (guiExtension != nullptr) extensionsPass = extensionsPass && !guiExtension->request_resize(&host->host, 640, 480) && !guiExtension->request_show(&host->host) && !guiExtension->request_hide(&host->host); + if (instance->getParameters().size() != 3 + || std::any_of(instance->getParameters().begin(), instance->getParameters().end(), + [](const auto* parameter) { return parameter == nullptr; })) + return setupFailure("Initial CLAP parameter enumeration is incomplete"); PluginParameterCapture capture(instance); instance->prepareToPlay(48000, 64); bool pass = instance->getParameters().size() == 3 && instance->getParameters()[0]->isAutomatable() @@ -198,16 +210,19 @@ juce::var runOpenStudioCLAPAutomationRegression() fixture.gain = 0.75; fixture.phase = 1; const auto* extension = static_cast(host->host.get_extension(&host->host, CLAP_EXT_PARAMS)); + if (extension == nullptr) return setupFailure("CLAP parameter host extension is unavailable"); extension->request_flush(&host->host); flushOpenStudioCLAPParameterEvents(instance.get()); capture.drain("track", false, 0, events); + const auto gestureState = capture.stateFor(0); pass = pass && events.size() == 2 && events[0]["phase"].toString() == "begin" - && events[1]["phase"].toString() == "value" && capture.stateFor(0)->gesture.load() + && events[1]["phase"].toString() == "value" && gestureState != nullptr && gestureState->gesture.load() && std::abs(instance->getParameters()[0]->getValue() - 0.75f) < 1.0e-6f; events.clear(); juce::Thread::sleep(220); capture.drain("track", false, 0, events); - pass = pass && events.isEmpty() && capture.stateFor(0)->touching.load(); + const auto idleTouchState = capture.stateFor(0); + pass = pass && events.isEmpty() && idleTouchState != nullptr && idleTouchState->touching.load(); fixture.phase = 2; extension->request_flush(&host->host); flushOpenStudioCLAPParameterEvents(instance.get()); @@ -243,9 +258,13 @@ juce::var runOpenStudioCLAPAutomationRegression() host->poll(CLAP_PARAM_RESCAN_ALL, false); events.clear(); capture.drain("track", false, 0, events); + const auto* appendedParameter = instance->getParameters().size() == 4 + ? dynamic_cast(instance->getParameters()[3]) : nullptr; + if (appendedParameter == nullptr) + return setupFailure("CLAP topology rescan did not append a hosted parameter"); const bool topologyPass = instance->getParameters().size() == 4 && instance->getParameters()[1] == originalChoice && !instance->getParameters()[0]->isAutomatable() && instance->getParameters()[3]->isAutomatable() - && dynamic_cast(instance->getParameters()[3])->getParameterID() == "3" + && appendedParameter->getParameterID() == "3" && std::count_if(events.begin(), events.end(), [](const juce::var& event) { return event["phase"].toString() == "metadata"; }) == 1 && std::none_of(events.begin(), events.end(), [](const juce::var& event) { return event["phase"].toString() == "value"; }); instance->getParameters()[3]->setValue(.25f); @@ -318,7 +337,8 @@ juce::var runOpenStudioCLAPAutomationRegression() extension->clear(&host->host, 3, CLAP_PARAM_CLEAR_ALL); host->poll(0, false); events.clear(); timedCapture.drain("track", false, 0, events); getOpenStudioCLAPParameterReferenceGeneration(instance.get(), 3, referenceGeneration); - clearPass = clearPass && referenceGeneration == 2 && !timedCapture.stateFor(3)->touching.load(); + const auto clearedState = timedCapture.stateFor(3); + clearPass = clearPass && referenceGeneration == 2 && clearedState != nullptr && !clearedState->touching.load(); // Producers never retry a dropped edit. Verify bounded contention does not // corrupt accepted events, reorder a producer, or prevent queue reuse. @@ -374,8 +394,10 @@ juce::var runOpenStudioCLAPAutomationRegression() graph->setParameterTree(juce::AudioProcessorParameterGroup("root", "Root", "/", std::make_unique(juce::ParameterID("replacement", 1), "Replacement", 0.0f, 2.0f, .5f))); events.clear(); replacementCapture.drain("replacement", false, 0, events); + const auto replacementState = replacementCapture.stateFor(0); bool replacementPass = events.size() == 1 && events[0]["phase"].toString() == "metadata" - && !replacementCapture.stateFor(0)->touching.load() && originalMeaning != pluginParameterMeaning(graph.get(), 0); + && replacementState != nullptr && !replacementState->touching.load() + && originalMeaning != pluginParameterMeaning(graph.get(), 0); events.clear(); graph->getParameters()[0]->setValueNotifyingHost(.8f); replacementCapture.drain("replacement", false, 0, events); replacementPass = replacementPass && events.size() == 1 && events[0]["phase"].toString() == "value" && events[0]["metadata"]["hostParamId"].toString() == "replacement"; diff --git a/Source/FreePluginClearReverbRegression.h b/Source/FreePluginClearReverbRegression.h index f499e81..6b39194 100644 --- a/Source/FreePluginClearReverbRegression.h +++ b/Source/FreePluginClearReverbRegression.h @@ -3,7 +3,7 @@ inline juce::var checkClearReverb() { auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Clear plate, room and randomized networks"); - bool finite = true, distinct = true, partition = true, controls = true, spillover = true, hold = true; + bool finite = true, distinct = true, partition = true, controls = true, spillover = true, hold = true, configurationCache = true; juce::Array cases; const auto render = [](double rate, int mode, BuiltInClearReverb::Settings settings, int block, bool retire, bool retain, float impulse) { @@ -55,6 +55,18 @@ inline juce::var checkClearReverb() engine->reset(); settings.holdInput = true; engine->configure(mode, settings, false); double infinite = 0; for (int i = 0; i < static_cast(rate * .25); ++i) { const auto out = engine->process(i == 0 ? .2f : 0.0f, 0); infinite += std::abs(out[0]) + std::abs(out[1]); } hold = hold && frozen == 0 && infinite > .001; + // Repeated host callbacks must not rebuild unchanged coefficients. + // Parameter edits, retirement (including releasing Hold), re-entry + // and reset must still invalidate the prepared targets. + const auto before = engine->coefficientUpdateCount(static_cast(mode)); + for(int callback=0;callback<16;++callback)engine->configure(mode,settings,false); + configurationCache = configurationCache && engine->coefficientUpdateCount(static_cast(mode))==before; + settings.speed=.71f;engine->configure(mode,settings,false); + engine->configure(-1,settings,true);engine->configure(-1,settings,true); + engine->configure(mode,settings,false); + configurationCache = configurationCache && engine->coefficientUpdateCount(static_cast(mode))==before+3; + engine->reset();engine->configure(mode,settings,false); + configurationCache = configurationCache && engine->coefficientUpdateCount(static_cast(mode))==before+4; auto* row = new juce::DynamicObject(); row->setProperty("rate", rate); row->setProperty("mode", mode); row->setProperty("impulseEnergyDiagnostic", total); row->setProperty("controlDifference", difference(baseline, driven)); cases.add(row); } distinct = distinct && difference(modes[0], modes[1]) > .001 && difference(modes[0], modes[2]) > .001 && difference(modes[1], modes[2]) > .001; @@ -77,9 +89,10 @@ inline juce::var checkClearReverb() for (const auto& bank : copy->clearControls) for (size_t field = 0; field < bank.size(); ++field) recall = recall && bank[field].load() == OpenStudioReverb::clearDefaults[field]; const auto schema = describeFreePluginForRegression(*source); const bool appended = schema["parameters"].size() >= 703 && schema["parameters"][633]["id"].toString() == "reverbTypeExtended" && static_cast(schema["parameters"][538]["max"]) == 23 && static_cast(schema["parameters"][515]["max"]) == 20; - result->setProperty("pass", finite && distinct && partition && controls && spillover && hold && setters && recall && appended); + result->setProperty("pass", finite && distinct && partition && controls && spillover && hold && configurationCache && setters && recall && appended); result->setProperty("finiteImpulse", finite); result->setProperty("distinctNetworks", distinct); result->setProperty("exactPartitioning", partition); result->setProperty("controlInfluence", controls); result->setProperty("spillover", spillover); result->setProperty("freezeInfinite", hold); + result->setProperty("unchangedCoefficientCacheAndInvalidation",configurationCache); result->setProperty("setters", setters); result->setProperty("stateAndDefaults", recall); result->setProperty("appendedAndOldRange", appended); result->setProperty("cases", cases); result->setProperty("schema", schema); result->setProperty("decayCalibration", "diagnostic_only"); result->setProperty("audioQuality", "not_asserted"); return result; diff --git a/Source/FreePluginDynamicsAudit.h b/Source/FreePluginDynamicsAudit.h new file mode 100644 index 0000000..7501820 --- /dev/null +++ b/Source/FreePluginDynamicsAudit.h @@ -0,0 +1,319 @@ +#pragma once + +// These processors are not clippers: at zero attenuation/reduction their wet +// path must preserve finite floating-point headroom, not only sub-0 dBFS audio. +// Deliberate nonlinear compressor models and processor/host bypass are excluded. +inline juce::var checkDynamicsUnityHeadroom() +{ + bool passed = true; + juce::Array cases; + for (const bool compressor : {false, true}) + for (const double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (const int channels : {1, 2}) + for (const float amplitude : {.125f, 1.0f, 3.0f, 8.0f}) + { + std::unique_ptr processor; + if (compressor) + { + auto clean = std::make_unique(true); + clean->selectModel(1); + clean->audioCharacter.store(0); + clean->threshold.store(-24); clean->ratio.store(1); + clean->makeupGain.store(0); clean->autoMakeup.store(0); + clean->mix.store(1); + processor = std::move(clean); + } + else + { + auto gate = std::make_unique(true); + gate->range.store(0); gate->mix.store(1); + gate->attackMs.store(.01f); gate->holdMs.store(0); + gate->releaseMs.store(5); gate->detectorListen.store(0); + processor = std::move(gate); + } + constexpr int blockSize = 127; + processor->prepareToPlay(rate, blockSize); + const int latency = processor->getLatencySamples(); + const int settleSamples = juce::roundToInt(rate * .05); + const int sampleCount = latency + juce::roundToInt(rate * .15); + const auto input = [rate, amplitude] (int position, int channel) + { + if (position < 0) return 0.0f; + const float value = amplitude * static_cast(std::sin( + juce::MathConstants::twoPi * 997.0 * position / rate)); + return channel == 0 ? value : -value; + }; + juce::AudioBuffer audio(channels, blockSize); + juce::MidiBuffer midi; + bool finite = true; + double maximumError = 0, maximumOutput = 0, maximumReference = 0; + int alteredSamples = 0; + for (int start = 0; start < sampleCount; start += blockSize) + { + const int count = juce::jmin(blockSize, sampleCount - start); + audio.setSize(channels, count, false, false, true); + for (int channel = 0; channel < channels; ++channel) + for (int sample = 0; sample < count; ++sample) + audio.setSample(channel, sample, input(start + sample, channel)); + processor->processBlock(audio, midi); + for (int channel = 0; channel < channels; ++channel) + for (int sample = 0; sample < count; ++sample) + { + const float actual = audio.getSample(channel, sample); + finite = finite && std::isfinite(actual); + const int position = start + sample - latency; + if (position < settleSamples) continue; + const double expected = input(position, channel); + const double error = std::abs(static_cast(actual) - expected); + maximumError = juce::jmax(maximumError, error); + maximumOutput = juce::jmax(maximumOutput, std::abs(static_cast(actual))); + maximumReference = juce::jmax(maximumReference, std::abs(expected)); + if (error > 1.0e-6) ++alteredSamples; + } + } + const bool okay = finite && maximumError <= 1.0e-6; + passed = passed && okay; + auto* row = new juce::DynamicObject(); + row->setProperty("processor", compressor ? "Compressor Clean" : "Gate"); + row->setProperty("sampleRate", rate); row->setProperty("channels", channels); + row->setProperty("inputAmplitude", amplitude); row->setProperty("reportedLatency", latency); + row->setProperty("maximumErrorAfterLatencyAndSettling", maximumError); + row->setProperty("maximumOutput", maximumOutput); row->setProperty("maximumReference", maximumReference); + row->setProperty("alteredSamples", alteredSamples); row->setProperty("finite", finite); + row->setProperty("pass", okay); cases.add(row); + } + auto* result = new juce::DynamicObject(); + result->setProperty("pass", passed); result->setProperty("cases", cases); + result->setProperty("scope", "Active 100% wet Gate Range0 and Legacy-character Clean Compressor Ratio1/Makeup0/AutoMakeupOff must preserve the same finite physical input, including peaks above +8 dBFS, after reported latency and startup settling. Intentional colour, hardware headroom and audible quality are not asserted."); + return result; +} + +// Audit evidence, not a production DSP change. A successful capture does not +// imply that the behavioral contracts below passed or that audio was auditioned. +inline juce::var auditFreePluginDynamics() +{ + const auto folder = juce::File::getCurrentWorkingDirectory() + .getChildFile(juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_OUTPUT", "output/free-plugin-audit")).getChildFile("dynamics/native"); + bool evidenceValid = true, signalContracts = true; + juce::Array foldbackCases, bypassCases, compressorCases; + bool compressorLawPass = true; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + { + for (int type : {3, 7}) for (int oversampling : {0, 1, 2}) + { + OpenStudioSaturator effect; + effect.satType.store(static_cast(type)); + effect.drive.store(0); effect.mix.store(1); effect.outputGain.store(0); + effect.asymmetry.store(0); effect.colourEngine.store(0); + effect.setOversamplingMode(static_cast(oversampling)); + effect.prepareToPlay(rate, 127); + const int count = juce::roundToInt(rate * .5); + juce::AudioBuffer captured(2, count), audio(2, 127); + juce::MidiBuffer midi; + double sumEnergy = 0, referenceEnergy = 0, maximumSum = 0; + for (int start = 0; start < count; start += 127) + { + const int block = juce::jmin(127, count - start); + audio.setSize(2, block, false, false, true); + for (int i = 0; i < block; ++i) + { + const float sample = static_cast(std::sin( + juce::MathConstants::twoPi * 997 * (start + i) / rate)); + audio.setSample(0, i, sample); audio.setSample(1, i, -sample); + } + effect.processBlock(audio, midi); + for (int ch = 0; ch < 2; ++ch) captured.copyFrom(ch, start, audio, ch, 0, block); + for (int i = 0; i < block; ++i) + { + const double left = audio.getSample(0, i), right = audio.getSample(1, i); + evidenceValid = evidenceValid && std::isfinite(left) && std::isfinite(right); + if (start + i < rate * .1) continue; + const double sum = left + right; + maximumSum = juce::jmax(maximumSum, std::abs(sum)); + sumEnergy += sum * sum; referenceEnergy += left * left; + } + } + auto* row = new juce::DynamicObject(); + row->setProperty("sampleRate", rate); row->setProperty("type", type); + row->setProperty("oversamplingFactor", 1 << oversampling); + row->setProperty("maximumAntiphaseSum", maximumSum); + row->setProperty("antiphaseResidualDbRelativeToLeft", 10 * std::log10( + juce::jmax(1.0e-30, sumEnergy / juce::jmax(1.0e-30, referenceEnergy)))); + row->setProperty("oddSymmetryStatus", maximumSum < 1.0e-6 ? "pass" : "fail"); + row->setProperty("settings", "Corrected signal path, Legacy colour engine, Drive 0 dB, Output 0 dB, Asymmetry 0, Mix 100%, 997 Hz 1.0-peak antiphase stereo"); + if (rate == 48000) + { + const auto file = folder.getChildFile("saturator-type" + juce::String(type) + + "-os" + juce::String(1 << oversampling) + "-antiphase.wav"); + evidenceValid = writeProbeWave(file, captured, rate) && evidenceValid; + row->setProperty("render", file.getFullPathName()); + } + signalContracts = signalContracts && maximumSum < 1e-6; + foldbackCases.add(row); + } + for (int character : {0, 1}) for (double frequency : {997.0, 10000.0}) + { + OpenStudioUtilityEffect effect(OpenStudioUtilityEffect::Kind::Preamp); + effect.setControl("audioCharacter", static_cast(character)); + effect.setControl("bypass", 1); effect.prepareToPlay(rate, 127); + const int count = juce::roundToInt(rate * .35), latency = effect.getLatencySamples(); + juce::AudioBuffer audio(2, 127); + juce::MidiBuffer midi; + double errorEnergy = 0, referenceEnergy = 0, maximumError = 0, outputEnergy = 0; + const auto signal = [rate, frequency](int position) + { + return position < 0 ? 0.0f : static_cast(.1 * std::sin( + juce::MathConstants::twoPi * frequency * position / rate)); + }; + for (int start = 0; start < count; start += 127) + { + const int block = juce::jmin(127, count - start); + audio.setSize(2, block, false, false, true); + for (int i = 0; i < block; ++i) + for (int ch = 0; ch < 2; ++ch) audio.setSample(ch, i, signal(start + i)); + effect.processBlock(audio, midi); + for (int i = 0; i < block; ++i) + { + const double output = audio.getSample(0, i); + evidenceValid = evidenceValid && std::isfinite(output); + if (start + i < rate * .1) continue; + const double reference = signal(start + i - latency), error = output - reference; + maximumError = juce::jmax(maximumError, std::abs(error)); + errorEnergy += error * error; referenceEnergy += reference * reference; + outputEnergy += output * output; + } + } + auto* row = new juce::DynamicObject(); + row->setProperty("sampleRate", rate); row->setProperty("character", character); + row->setProperty("frequencyHz", frequency); row->setProperty("latencySamples", latency); + row->setProperty("maximumErrorAfterReportedLatency", maximumError); + row->setProperty("nullResidualDbRelativeToDry", 10 * std::log10( + juce::jmax(1.0e-30, errorEnergy / referenceEnergy))); + row->setProperty("bypassedGainDb", 10 * std::log10(outputEnergy / referenceEnergy)); + row->setProperty("status", maximumError < 1e-7 ? "pass" : "fail"); + signalContracts = signalContracts && maximumError < 1e-7; + row->setProperty("contract", "A bypassed processor should preserve the waveform after its reported latency; Corrected uses the native-rate dry path."); + bypassCases.add(row); + } + for (float ratio : {1.0f, 2.0f, 4.0f, 20.0f}) + for (float peakDb : {-36.0f, -24.0f, -12.0f, -3.0f}) + { + OpenStudioCompressor effect(true); + effect.selectModel(1); effect.audioCharacter.store(0); + effect.threshold.store(-24); effect.ratio.store(ratio); effect.knee.store(0); + effect.attack.store(5); effect.release.store(100); effect.autoRelease.store(0); + effect.makeupGain.store(0); effect.autoMakeup.store(0); effect.mix.store(1); + effect.detectorMode.store(1); effect.stereoLink.store(1); effect.sidechainHPF.store(20); + effect.prepareToPlay(rate, 127); + const int count = juce::roundToInt(rate), latency = effect.getLatencySamples(); + const float level = juce::Decibels::decibelsToGain(peakDb); + juce::AudioBuffer audio(2, 127); + juce::MidiBuffer midi; + double inputEnergy = 0, outputEnergy = 0, symmetryError = 0; + const auto signal = [rate, level](int position) + { + return position < 0 ? 0.0f : static_cast(level * std::sin( + juce::MathConstants::twoPi * 997 * position / rate)); + }; + for (int start = 0; start < count; start += 127) + { + const int block = juce::jmin(127, count - start); + audio.setSize(2, block, false, false, true); + for (int i = 0; i < block; ++i) + { + const float sample = signal(start + i); + audio.setSample(0, i, sample); audio.setSample(1, i, -sample); + } + effect.processBlock(audio, midi); + for (int i = 0; i < block; ++i) + { + evidenceValid = evidenceValid && std::isfinite(audio.getSample(0, i)); + if (start + i < rate * .6) continue; + const double input = signal(start + i - latency), output = audio.getSample(0, i); + inputEnergy += input * input; outputEnergy += output * output; + symmetryError = juce::jmax(symmetryError, + std::abs(output + audio.getSample(1, i))); + } + } + const double expected = -juce::jmax(0.0, static_cast(peakDb) - 3.01029995664 + 24) + * (1.0 - 1.0 / ratio); + const double measured = 10 * std::log10(outputEnergy / inputEnergy); + const bool law = std::abs(measured - expected) < .05 && symmetryError < 1.0e-6; + compressorLawPass = compressorLawPass && law; + auto* row = new juce::DynamicObject(); + row->setProperty("sampleRate", rate); row->setProperty("ratio", ratio); + row->setProperty("inputPeakDbFS", peakDb); row->setProperty("expectedGainDb", expected); + row->setProperty("measuredGainDb", measured); row->setProperty("errorDb", measured - expected); + row->setProperty("antiphaseError", symmetryError); row->setProperty("status", law ? "pass" : "fail"); + row->setProperty("settings", "Clean, RMS, Threshold -24 dB, Knee 0, Attack 5 ms, Release 100 ms, no Auto/Makeup, 997 Hz antiphase sine, Mix 100%"); + compressorCases.add(row); + } + } + const auto renderTransition = [](int blockSize, bool changing) + { + constexpr int length = 14400, event = 4800; + OpenStudioSaturator effect; + effect.satType.store(0); effect.drive.store(18); effect.mix.store(1); + effect.setOversamplingMode(0); effect.prepareToPlay(48000, blockSize); + std::vector output(static_cast(length)); + juce::AudioBuffer audio(2, blockSize); juce::MidiBuffer midi; + for (int start = 0; start < length;) + { + int count = juce::jmin(blockSize, length - start); + if (start < event) count = juce::jmin(count, event - start); + if (changing && start == event) effect.satType.store(3); + audio.setSize(2, count, false, false, true); + for (int i = 0; i < count; ++i) + { + const float sample = .25f * std::sin(juce::MathConstants::twoPi + * 997.0f * static_cast(start + i) / 48000.0f); + audio.setSample(0, i, sample); audio.setSample(1, i, -sample); + } + effect.processBlock(audio, midi); + for (int i = 0; i < count; ++i) output[static_cast(start + i)] = audio.getSample(0, i); + start += count; + } + return output; + }; + juce::Array transitionCases; + const auto reference = renderTransition(64, true), staticReference = renderTransition(64, false); + for (int blockSize : {1, 32, 127, 512, 1024}) + { + const auto candidate = renderTransition(blockSize, true), staticCandidate = renderTransition(blockSize, false); + double maximumError = 0, staticError = 0; int drySamples = 0; + for (size_t i = 0; i < candidate.size(); ++i) + { + evidenceValid = evidenceValid && std::isfinite(candidate[i]); + maximumError = juce::jmax(maximumError, std::abs(static_cast(candidate[i] - reference[i]))); + staticError = juce::jmax(staticError, std::abs(static_cast(staticCandidate[i] - staticReference[i]))); + if (i >= 5280 && i < 6800) + { + const float input = .25f * std::sin(juce::MathConstants::twoPi + * 997.0f * static_cast(i) / 48000.0f); + if (std::abs(candidate[i] - input) < 1.0e-7) ++drySamples; + } + } + auto* row = new juce::DynamicObject(); row->setProperty("blockSize", blockSize); + row->setProperty("maximumErrorAgainst64SampleBlocks", maximumError); + row->setProperty("staticPartitionError", staticError); + row->setProperty("drySamplesAfterTenMsFade", drySamples); + row->setProperty("status", maximumError < 1e-6 && staticError < 1e-6 && drySamples == 0 ? "pass" : "fail"); + signalContracts = signalContracts && maximumError < 1e-6 && staticError < 1e-6 && drySamples == 0; + row->setProperty("settings", "48 kHz, Corrected signal path, Legacy colour engine Tape to Clip exactly at sample4800, OversamplingOff, Drive18, Mix100, 997Hz/0.25peak; both partitions split at event"); + transitionCases.add(row); + } + auto* result = new juce::DynamicObject(); + const auto unityHeadroom = checkDynamicsUnityHeadroom(); + result->setProperty("plugin", "Dynamics and colour independent audit"); + result->setProperty("pass", evidenceValid && signalContracts && compressorLawPass + && static_cast(unityHeadroom.getProperty("pass", false))); + result->setProperty("passMeaning", "Deterministic symmetry, bypass null, transfer law, active unity headroom and transition timing; audio quality not asserted."); + result->setProperty("activeUnityHeadroom", unityHeadroom); + result->setProperty("saturatorAntiphase", foldbackCases); + result->setProperty("preampInternalBypass", bypassCases); + result->setProperty("compressorCleanRmsCurve", compressorCases); + result->setProperty("compressorCleanRmsCurveStatus", compressorLawPass ? "pass" : "fail"); + result->setProperty("saturatorTypeTransitionPartitions", transitionCases); + result->setProperty("audioQuality", "not_asserted"); + return result; +} diff --git a/Source/FreePluginEQPhasePitchAudit.h b/Source/FreePluginEQPhasePitchAudit.h new file mode 100644 index 0000000..e142e30 --- /dev/null +++ b/Source/FreePluginEQPhasePitchAudit.h @@ -0,0 +1,225 @@ +#pragma once + +// Audit evidence, deliberately separate from the passing regression baseline. +// Pitch frequency estimates remain diagnostic; this does not assert listening quality. +inline juce::var auditEQPhasePitchContracts() +{ + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "EQ, Gain Phase and realtime Pitch Correct audit"); + result->setProperty("audioQuality", "not_asserted"); + juce::Array noteCases, detectorCases, tailCases, eqCases, filterTailCases; + bool absoluteNoteContract = true, tailContract = true, eqLinearity = true, filterTailContract = true; + + // A static linear EQ should obey amplitude homogeneity even when an + // intermediate floating-point bus exceeds 0 dBFS. These are within the + // exposed +30 dB band range, with dynamics and auto gain disabled. + for (int phase = 0; phase < 2; ++phase) + { + const auto render = [phase](float amplitude) + { + auto eq = std::make_unique(true); + eq->setNonRealtime(true); + for (auto& band : eq->bands) band.enabled.store(0); + eq->bands[1].enabled.store(1); eq->bands[1].freq.store(1000); + eq->bands[1].gain.store(18); eq->bands[1].q.store(1); + eq->setPhaseConfiguration(static_cast(phase), 1); + eq->prepareToPlay(48000, 127); + juce::AudioBuffer rendered(2, 24000), block(2, 127); + juce::MidiBuffer midi; + for (int start = 0; start < rendered.getNumSamples(); start += 127) + { + const int count = juce::jmin(127, rendered.getNumSamples() - start); + block.setSize(2, count, false, false, true); + for (int ch = 0; ch < 2; ++ch) for (int i = 0; i < count; ++i) + block.setSample(ch, i, amplitude * static_cast(std::sin(juce::MathConstants::twoPi * (start + i) / 48.0))); + eq->processBlock(block, midi); + for (int ch = 0; ch < 2; ++ch) rendered.copyFrom(ch, start, block, ch, 0, count); + } + return rendered; + }; + const auto quiet = render(.01f), loud = render(.5f); + double error = 0, referencePeak = 0, actualPeak = 0; + int guardedSamples = 0; + for (int i = 12000; i < loud.getNumSamples(); ++i) + { + const double reference = quiet.getSample(0, i) * 50.0, actual = loud.getSample(0, i); + error = juce::jmax(error, std::abs(reference - actual)); + referencePeak = juce::jmax(referencePeak, std::abs(reference)); actualPeak = juce::jmax(actualPeak, std::abs(actual)); + if (std::abs(actual) == 2.5) ++guardedSamples; + } + const bool pass = error < 1e-4; + eqLinearity = eqLinearity && pass; + auto* row = new juce::DynamicObject(); + row->setProperty("phase", phase == 0 ? "IIR" : "Linear FIR"); + row->setProperty("inputPeak", .5); row->setProperty("bandBoostDb", 18); + row->setProperty("expectedLinearPeak", referencePeak); row->setProperty("actualPeak", actualPeak); + row->setProperty("amplitudeHomogeneityError", error); row->setProperty("guardedSamplesOf12000", guardedSamples); + row->setProperty("pass", pass); eqCases.add(row); + } + + // Public descriptors name noteEnable_0 C, etc.; verify those named targets + // remain the same absolute pitch classes when the root changes. + for (int root = 0; root < 12; ++root) + { + PitchMapper mapper; + mapper.setCorrectedNoteMask(true); + mapper.prepare(48000); + mapper.setKey(root); + mapper.setScale(PitchMapper::Scale::Custom); + for (int note = 0; note < 12; ++note) mapper.setNoteEnabled(note, note == 0); + const float requestedHz = 261.625565f; + const float targetHz = mapper.getSnapTarget(requestedHz); + const int targetMidi = juce::roundToInt(69.0f + 12.0f * std::log2(targetHz / 440.0f)); + const int targetClass = ((targetMidi % 12) + 12) % 12; + const bool pass = targetClass == 0; + absoluteNoteContract = absoluteNoteContract && pass; + auto* row = new juce::DynamicObject(); + row->setProperty("root", root); row->setProperty("enabledDescriptor", "C only"); + row->setProperty("inputMidi", 60); row->setProperty("mappedMidi", targetMidi); + row->setProperty("targetHz", targetHz); row->setProperty("pass", pass); + noteCases.add(row); + } + + // The suite only exposes individual note toggles in Custom. Their retained + // value must not silently remove targets after selecting a named scale. + PitchMapper maskedMapper; + maskedMapper.setCorrectedNoteMask(true); + maskedMapper.prepare(48000); maskedMapper.setKey(0); + maskedMapper.setScale(PitchMapper::Scale::Custom); + for (int note = 0; note < 12; ++note) maskedMapper.setNoteEnabled(note, note == 0); + maskedMapper.setScale(PitchMapper::Scale::Major); + const float majorTarget = maskedMapper.getSnapTarget(329.627557f); + const int majorTargetMidi = juce::roundToInt(69.0f + 12.0f * std::log2(majorTarget / 440.0f)); + const bool hiddenMaskContract = majorTargetMidi == 64; + auto* hiddenMask = new juce::DynamicObject(); + hiddenMask->setProperty("steps", "Custom, enable only C, switch Major, request E4"); + hiddenMask->setProperty("expectedMidi", 64); hiddenMask->setProperty("actualMidi", majorTargetMidi); + hiddenMask->setProperty("pass", hiddenMaskContract); + result->setProperty("hiddenCustomMask", hiddenMask); + + // The exact frame/lag bound is the structural issue. Estimates demonstrate + // its practical consequences on deterministic monophonic inputs. + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (double hz : {55.0, 82.406889, 110.0, 164.813778, 220.0, 440.0}) + { + PitchDetector detector; + detector.setRateNormalized(true); + detector.prepare(rate, 512); detector.setMinFrequency(40); detector.setMaxFrequency(1000); + std::array input {}; + const int total = juce::roundToInt(rate * .2); + for (int start = 0; start < total; start += 512) + { + const int count = juce::jmin(512, total - start); + for (int i = 0; i < count; ++i) + input[static_cast(i)] = static_cast(.2 * std::sin(juce::MathConstants::twoPi * hz * (start + i) / rate)); + detector.processSamples(input.data(), count); + } + auto* row = new juce::DynamicObject(); + const double detected = detector.getDetectedFrequency(); + row->setProperty("sampleRate", rate); row->setProperty("inputHz", hz); + row->setProperty("configuredMinimumHz", 40); row->setProperty("maximumLag", 1023); + row->setProperty("minimumFrequencyFromLagHz", (rate / std::ceil(rate / 32000.0)) / 1023); + row->setProperty("onePeriodFitsSearch", (rate / std::ceil(rate / 32000.0)) / hz <= 1023); + row->setProperty("detectedHz", detected); row->setProperty("confidence", detector.getConfidence()); + row->setProperty("centsError", detected > 0 ? juce::var(1200 * std::log2(detected / hz)) : juce::var()); + row->setProperty("status", "diagnostic_only"); detectorCases.add(row); + } + + using Utility = OpenStudioUtilityEffect; + for (double rate : {44100.0, 48000.0, 192000.0}) + for (int mode = 0; mode < 3; ++mode) + { + Utility processor(Utility::Kind::GainPhase); + processor.setNonRealtime(true); + if (mode == 0) { processor.setControl("delayL", 2048); processor.setControl("delayR", 2048); } + if (mode == 1) { processor.setControl("phaseEnabledL", 1); processor.setControl("phaseFrequencyL", 20); processor.setControl("phaseStagesL", 3); } + if (mode == 2) { processor.setControl("spectralPhaseEnabled", 1); processor.setControl("spectralPhaseL12", 90); } + processor.prepareToPlay(rate, 127); + const int latency = processor.getLatencySamples(); + const double declaredTail = processor.getTailLengthSeconds(); + const int declaredEnd = latency + juce::roundToInt(declaredTail * rate); + juce::AudioBuffer block(2, 127); juce::MidiBuffer midi; + int lastNonzero = -1; double energyAfterDeclaredEnd = 0, maximum = 0; + bool finite = true; + for (int start = 0; start < declaredEnd + juce::roundToInt(rate * .5); start += 127) + { + const int count = juce::jmin(127, declaredEnd + juce::roundToInt(rate * .5) - start); + block.setSize(2, count, false, false, true); block.clear(); + if (start == 0) { block.setSample(0, 0, .25f); block.setSample(1, 0, .25f); } + processor.processBlock(block, midi); + for (int ch = 0; ch < 2; ++ch) for (int i = 0; i < count; ++i) + { + const double value = block.getSample(ch, i); + finite = finite && std::isfinite(value); maximum = juce::jmax(maximum, std::abs(value)); + if (std::abs(value) > 1e-6) lastNonzero = juce::jmax(lastNonzero, start + i); + if (start + i > declaredEnd) energyAfterDeclaredEnd += value * value; + } + } + const bool pass = finite && energyAfterDeclaredEnd < 1e-12; + tailContract = tailContract && pass; + auto* row = new juce::DynamicObject(); + row->setProperty("sampleRate", rate); row->setProperty("mode", mode == 0 ? "2048 sample delay" : mode == 1 ? "20 Hz four-stage allpass" : "spectral phase FIR"); + row->setProperty("declaredTailSeconds", declaredTail); row->setProperty("latencySamples", latency); + row->setProperty("lastSampleAboveMinus120dBFS", lastNonzero); row->setProperty("energyAfterDeclaredTail", energyAfterDeclaredEnd); + row->setProperty("peak", maximum); row->setProperty("finite", finite); row->setProperty("pass", pass); tailCases.add(row); + } + + for (double rate : {44100.0, 48000.0, 192000.0}) + for (int kind = 0; kind < 2; ++kind) + { + std::unique_ptr processor; + if (kind == 0) + { + auto eq = std::make_unique(true); + for (auto& band : eq->bands) band.enabled.store(0); + eq->bands[1].enabled.store(1); eq->bands[1].freq.store(40); + eq->bands[1].gain.store(12); eq->bands[1].q.store(30); + processor = std::move(eq); + } + else + { + auto eq = std::make_unique(Utility::Kind::GraphicEQ); + eq->setControl("graphicMode", 1); eq->setControl("third0", 12); + processor = std::move(eq); + } + processor->setNonRealtime(true); processor->prepareToPlay(rate, 127); + const int latency = processor->getLatencySamples(); + const double declared = processor->getTailLengthSeconds(); + const int declaredEnd = latency + juce::roundToInt(declared * rate); + juce::AudioBuffer block(2, 127); juce::MidiBuffer midi; + double energyAfter = 0; int last = -1; bool finite = true; + for (int start = 0; start < declaredEnd + juce::roundToInt(rate * .5); start += 127) + { + const int count = juce::jmin(127, declaredEnd + juce::roundToInt(rate * .5) - start); + block.setSize(2, count, false, false, true); block.clear(); + if (start == 0) { block.setSample(0, 0, .25f); block.setSample(1, 0, .25f); } + processor->processBlock(block, midi); + for (int ch = 0; ch < 2; ++ch) for (int i = 0; i < count; ++i) + { + const double value = block.getSample(ch, i); + finite = finite && std::isfinite(value); + if (std::abs(value) > 1e-6) last = juce::jmax(last, start + i); + if (start + i > declaredEnd) energyAfter += value * value; + } + } + const bool pass = finite && energyAfter < 1e-12; + filterTailContract = filterTailContract && pass; + auto* row = new juce::DynamicObject(); + row->setProperty("processor", kind == 0 ? "Parametric EQ IIR 40 Hz Q30 +12 dB" : "Graphic EQ 31-band 20 Hz +12 dB"); + row->setProperty("sampleRate", rate); row->setProperty("declaredTailSeconds", declared); + row->setProperty("latencySamples", latency); row->setProperty("energyAfterDeclaredTail", energyAfter); + row->setProperty("lastSampleAboveMinus120dBFS", last); row->setProperty("captureSamples", declaredEnd + juce::roundToInt(rate * .5)); + row->setProperty("finite", finite); row->setProperty("pass", pass); filterTailCases.add(row); + } + result->setProperty("absoluteNoteLabels", noteCases); + result->setProperty("eqAmplitudeHomogeneity", eqCases); + result->setProperty("eqLinearityContract", eqLinearity); + result->setProperty("absoluteNoteContract", absoluteNoteContract); + result->setProperty("pitchDetectorRange", detectorCases); + result->setProperty("gainPhaseTail", tailCases); + result->setProperty("gainPhaseTailContract", tailContract); + result->setProperty("iirFilterTail", filterTailCases); + result->setProperty("iirFilterTailContract", filterTailContract); + result->setProperty("pass", absoluteNoteContract && tailContract && eqLinearity && filterTailContract && hiddenMaskContract); + return result; +} diff --git a/Source/FreePluginInstrumentAudit.h b/Source/FreePluginInstrumentAudit.h new file mode 100644 index 0000000..b53e1a4 --- /dev/null +++ b/Source/FreePluginInstrumentAudit.h @@ -0,0 +1,148 @@ +#pragma once + +// Diagnostic captures for the October 2026 free-plugin audit. This does not +// change production processing and does not assert subjective audio quality. +inline juce::var auditFreeInstrumentSignals() +{ + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile(juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_OUTPUT", "output/free-plugin-audit")).getChildFile("instruments/native"); + directory.createDirectory(); + using InstrumentFactory = std::function()>; + const std::array names {"synth-default", "synth-sine", "piano-legacy", "piano-expressive", "guitar-legacy", "guitar-loop", "drums-legacy", "drums-articulated"}; + const auto factory = [] (int kind, int variant = 0) -> std::unique_ptr + { + if (kind < 2) + { + auto p = std::make_unique(); + if (kind == 1) { p->oscillatorAShape.store(3); p->oscillatorBlend.store(0); p->subLevel.store(0); p->noiseLevel.store(0); p->detuneCents.store(0); } + if (variant == 1) p->brightness.store(0); + if (variant == 2) p->brightness.store(1); + if (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_LEGACY", "") == "1") p->signalPath.store(0); + return p; + } + if (kind < 4) + { + auto p = std::make_unique(); p->performanceMode.store(kind == 3 ? 1.0f : 0.0f); + if (variant > 0) p->model.store(static_cast(variant - 1)); + if (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_LEGACY", "") == "1") p->signalPath.store(0); + return p; + } + if (kind < 6) + { + auto p = std::make_unique(); p->stringEngine.store(kind == 5 ? 1.0f : 0.0f); p->stringMode.store(0); + if (variant > 0) p->model.store(static_cast(variant - 1)); + if (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_LEGACY", "") == "1") p->signalPath.store(0); + return p; + } + auto p = std::make_unique(); p->articulationEngine.store(kind == 7 ? 1.0f : 0.0f); + if (variant >= 1 && variant <= 3) p->kit.store(static_cast(variant - 1)); + if (variant == 4) p->hihatTightness.store(0); + if (variant == 5) p->hihatTightness.store(1); + if (variant == 6) p->ambience.store(0); + if (variant == 7) p->ambience.store(1); + if (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_LEGACY", "") == "1") p->signalPath.store(0); + return p; + }; + const auto midiFor = [] (int kind, double rate, float velocity, int note = 69) + { + juce::MidiBuffer midi; + if (kind >= 6) + { + const std::array notes {36, 38, 42, 46, 41, 48, 49, 51}; + for (size_t i = 0; i < notes.size(); ++i) midi.addEvent(juce::MidiMessage::noteOn(10, notes[i], velocity), 37 + juce::roundToInt(static_cast(i) * rate * .25)); + } + else + { + midi.addEvent(juce::MidiMessage::noteOn(1, note, velocity), 37); + midi.addEvent(juce::MidiMessage::noteOff(1, note), juce::roundToInt(rate * 1.5)); + } + return midi; + }; + const auto render = [] (const InstrumentFactory& make, double rate, const juce::MidiBuffer& events, bool fillInput = false, int partition = 257) + { + auto processor = make(); processor->prepareToPlay(rate, partition); + const int length = juce::roundToInt(rate * 3); + juce::AudioBuffer output(2, length), block(2, partition); juce::MidiBuffer midi; + for (int start = 0; start < length; start += partition) + { + const int count = juce::jmin(partition, length - start); block.setSize(2, count, false, false, true); block.clear(); + if (fillInput) for (int ch = 0; ch < 2; ++ch) juce::FloatVectorOperations::fill(block.getWritePointer(ch), .2f, count); + midi.clear(); midi.addEvents(events, start, count, -start); processor->processBlock(block, midi); + for (int ch = 0; ch < 2; ++ch) output.copyFrom(ch, start, block, ch, 0, count); + } + return output; + }; + const auto difference = [] (const juce::AudioBuffer& a, const juce::AudioBuffer& b) + { + double result = 0; + for (int ch = 0; ch < a.getNumChannels(); ++ch) for (int i = 0; i < a.getNumSamples(); ++i) + result = juce::jmax(result, std::abs(static_cast(a.getSample(ch, i)) - b.getSample(ch, i))); + return result; + }; + juce::Array captures, controls, invariants; + bool capturePass = true; + for (const double rate : {44100.0, 48000.0, 96000.0, 192000.0}) for (int kind = 0; kind < 8; ++kind) + { + const auto midi = midiFor(kind, rate, 1.0f); + const auto audio = render([&] { return factory(kind); }, rate, midi); + bool finite = true; double mean = 0, square = 0, peak = 0; int overs = 0; + for (int ch = 0; ch < 2; ++ch) for (int i = 0; i < audio.getNumSamples(); ++i) + { + const double value = audio.getSample(ch, i); finite = finite && std::isfinite(value); mean += value; square += value * value; peak = juce::jmax(peak, std::abs(value)); if (std::abs(value) > 1) ++overs; + } + const auto file = directory.getChildFile(juce::String(names[static_cast(kind)]) + "-" + juce::String(static_cast(rate)) + ".wav"); + const bool written = writeProbeWave(file, audio, rate); capturePass = capturePass && finite && written && peak > 0; + auto* row = new juce::DynamicObject(); row->setProperty("kind", names[static_cast(kind)]); row->setProperty("rate", rate); row->setProperty("path", file.getFullPathName()); row->setProperty("finite", finite); row->setProperty("written", written); row->setProperty("peak", peak); row->setProperty("overFullScaleSamples", overs); row->setProperty("mean", mean / (2 * audio.getNumSamples())); row->setProperty("rms", std::sqrt(square / (2 * audio.getNumSamples()))); captures.add(row); + if (rate != 48000) continue; + auto* check = new juce::DynamicObject(); check->setProperty("kind", names[static_cast(kind)]); + const auto alternate = render([&] { return factory(kind); }, rate, midi, false, 511); + check->setProperty("partitionMaximumError", difference(audio, alternate)); + const auto withInput = render([&] { return factory(kind); }, rate, midi, true); + check->setProperty("incomingAudioDiscardedMaximumError", difference(audio, withInput)); + auto source = factory(kind); juce::MemoryBlock saved; source->getStateInformation(saved); + const auto restored = render([&] { auto copy = factory(kind); copy->setStateInformation(saved.getData(), static_cast(saved.getSize())); return copy; }, rate, midi); + check->setProperty("stateAudioRoundTripMaximumError", difference(audio, restored)); + const auto low = render([&] { return factory(kind); }, rate, midiFor(kind, rate, .25f)); + const auto mid = render([&] { return factory(kind); }, rate, midiFor(kind, rate, .5f)); + check->setProperty("velocity25Rms", low.getRMSLevel(0, 0, low.getNumSamples())); check->setProperty("velocity50Rms", mid.getRMSLevel(0, 0, mid.getNumSamples())); check->setProperty("velocity100Rms", audio.getRMSLevel(0, 0, audio.getNumSamples())); + invariants.add(check); + if (kind >= 6) + { + const auto studio = render([&] { return factory(kind, 1); }, rate, midi); + const auto rock = render([&] { return factory(kind, 2); }, rate, midi); + const auto electronic = render([&] { return factory(kind, 3); }, rate, midi); + const auto loose = render([&] { return factory(kind, 4); }, rate, midi); + const auto tight = render([&] { return factory(kind, 5); }, rate, midi); + auto* comparison = new juce::DynamicObject(); comparison->setProperty("kind", names[static_cast(kind)]); comparison->setProperty("studioRockMaximumError", difference(studio, rock)); comparison->setProperty("studioElectronicMaximumError", difference(studio, electronic)); comparison->setProperty("hiHatTightnessMaximumError", difference(loose, tight)); controls.add(comparison); + for (const int variant : {2, 3, 4, 5, 6, 7}) + { + const auto changed = render([&] { return factory(kind, variant); }, rate, midi); + capturePass = writeProbeWave(directory.getChildFile(juce::String(names[static_cast(kind)]) + "-control-" + juce::String(variant) + ".wav"), changed, rate) && capturePass; + } + } + if (kind < 6) for (const int note : {40, 100}) + { + const auto changed = render([&] { return factory(kind); }, rate, midiFor(kind, rate, 1.0f, note)); + capturePass = writeProbeWave(directory.getChildFile(juce::String(names[static_cast(kind)]) + "-note-" + juce::String(note) + ".wav"), changed, rate) && capturePass; + } + } + juce::Array tailChecks; + for (const int kind : {6, 7}) + { + juce::MidiBuffer crash; crash.addEvent(juce::MidiMessage::noteOn(10, 49, 1.0f), 0); + const auto audio = render([&] { return factory(kind); }, 48000, crash); + const auto processor = factory(kind); const double reportedTail = processor->getTailLengthSeconds(); + auto* row = new juce::DynamicObject(); row->setProperty("kind", names[static_cast(kind)]); row->setProperty("declaredTailSeconds", reportedTail); + row->setProperty("rms2to3Seconds", audio.getRMSLevel(0, 96000, 48000)); row->setProperty("peak2to3Seconds", audio.getMagnitude(0, 96000, 48000)); + auto ordinaryOff = crash; ordinaryOff.addEvent(juce::MidiMessage::noteOff(10, 49), 48000); + auto allOff = crash; allOff.addEvent(juce::MidiMessage::allNotesOff(10), 48000); + const auto released = render([&] { return factory(kind); }, 48000, ordinaryOff); + const auto stopped = render([&] { return factory(kind); }, 48000, allOff); + row->setProperty("ordinaryNoteOffTailRms1to2Seconds", released.getRMSLevel(0, 48000, 48000)); + row->setProperty("allNotesOffTailRms1to2Seconds", stopped.getRMSLevel(0, 48000, 48000)); + row->setProperty("lastSampleBeforeAllNotesOff", stopped.getSample(0, 47999)); + row->setProperty("firstSampleAfterAllNotesOff", stopped.getSample(0, 48000)); tailChecks.add(row); + capturePass = writeProbeWave(directory.getChildFile(juce::String(names[static_cast(kind)]) + "-all-notes-off.wav"), stopped, 48000) && capturePass; + capturePass = writeProbeWave(directory.getChildFile(juce::String(names[static_cast(kind)]) + "-single-crash.wav"), audio, 48000) && capturePass; + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Four free instruments: diagnostic render audit"); result->setProperty("pass", capturePass); result->setProperty("captures", captures); result->setProperty("controlComparisons", controls); result->setProperty("invariants", invariants); result->setProperty("tailChecks", tailChecks); result->setProperty("audioQuality", "not_asserted"); result->setProperty("status", "diagnostic_only; pass means capture sanity, not all implementation expectations"); return result; +} diff --git a/Source/FreePluginMinimumEQRegression.h b/Source/FreePluginMinimumEQRegression.h index a2a7dcf..fbfd55f 100644 --- a/Source/FreePluginMinimumEQRegression.h +++ b/Source/FreePluginMinimumEQRegression.h @@ -79,7 +79,8 @@ inline juce::var checkMinimumPhaseEQ() statePass = statePass && restored->minimumPhaseFIR.load() == 0 && restored->getLatencySamples() == 0; const auto schema = describeFreePluginForRegression(*original); const auto* parameters = schema["parameters"].getArray(); - descriptors = parameters && parameters->size() >= 2 && (*parameters)[parameters->size()-2]["id"].toString() == "minimumPhaseFIR" && !static_cast((*parameters)[parameters->size()-2]["automatable"]); + // The frozen index must survive later appended configuration fields. + descriptors = parameters && parameters->size() > 716 && (*parameters)[716]["id"].toString() == "minimumPhaseFIR" && !static_cast((*parameters)[716]["automatable"]); result->setProperty("pass", finite && latency && causal && statePass && descriptors && targetError < .5 && graphError < .08 && partitionError < 1e-8 && bypassError == 0); result->setProperty("finite", finite); result->setProperty("latency256", latency); result->setProperty("causal", causal); result->setProperty("targetErrorDbAboveMinus60", targetError); result->setProperty("graphErrorDbAboveMinus60", graphError); diff --git a/Source/FreePluginOtherSignalAudit.h b/Source/FreePluginOtherSignalAudit.h new file mode 100644 index 0000000..198322d --- /dev/null +++ b/Source/FreePluginOtherSignalAudit.h @@ -0,0 +1,171 @@ +#pragma once + +// Offline evidence only; never invoked by the real-time plugin path. +inline juce::var auditChorusSignalContracts() +{ + const auto folder = juce::File::getCurrentWorkingDirectory().getChildFile(juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_OUTPUT", "output/free-plugin-audit")).getChildFile("modulation"); + juce::Array cases; + bool finite = true; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (int mode : {0, 1, 2}) for (float feedback : {0.0f, .9f}) + { + OpenStudioChorus p; + p.mode.store(static_cast(mode)); p.fbAmount.store(feedback); + p.mix.store(1); p.depth.store(0); p.voices.store(2); + p.prepareToPlay(rate, 127); + const double declared = p.getTailLengthSeconds(); + const int impulse = 1024, length = impulse + juce::roundToInt((declared + .5) * rate); + juce::AudioBuffer captured(2, length), block(2, 127); + juce::MidiBuffer midi; + double latePeak = 0, lateEnergy = 0; + int lateCount = 0, lastAbove = 0; + for (int start = 0; start < length; start += 127) + { + const int count = juce::jmin(127, length - start); + block.setSize(2, count, false, false, true); block.clear(); + if (impulse >= start && impulse < start + count) + for (int ch = 0; ch < 2; ++ch) block.setSample(ch, impulse - start, .5f); + p.processBlock(block, midi); + for (int ch = 0; ch < 2; ++ch) captured.copyFrom(ch, start, block, ch, 0, count); + for (int i = 0; i < count; ++i) + { + const double value = block.getSample(0, i); + finite = finite && std::isfinite(value); + if (std::abs(value) > .0001) lastAbove = start + i; + if (start + i > impulse + juce::roundToInt(declared * rate)) + { latePeak = juce::jmax(latePeak, std::abs(value)); lateEnergy += value * value; ++lateCount; } + } + } + auto* row = new juce::DynamicObject(); + row->setProperty("rate", rate); row->setProperty("mode", mode); row->setProperty("feedback", feedback); + row->setProperty("declaredTailSeconds", declared); row->setProperty("latePeak", latePeak); + row->setProperty("lateRms", std::sqrt(lateEnergy / juce::jmax(1, lateCount))); + row->setProperty("lastAboveMinus80DbSeconds", (lastAbove - impulse) / rate); + row->setProperty("tailContract", latePeak < .0001 ? "pass" : "fail"); + cases.add(row); + if (rate == 48000 && feedback > .5f) + finite = writeProbeWave(folder.getChildFile("tail-mode-" + juce::String(mode) + ".wav"), captured, rate) && finite; + } + juce::Array motion; + for (int character : {0, 1}) + { + constexpr double rate = 48000; + OpenStudioChorus p; p.characterMode.store(static_cast(character)); + p.rate.store(4); p.depth.store(1); p.mix.store(1); p.voices.store(4); p.spread.store(0); + p.prepareToPlay(rate, 127); + juce::AudioBuffer captured(2, 192000), block(2, 127); juce::MidiBuffer midi; + double maxStep = 0; float previous = 0; + for (int start = 0; start < captured.getNumSamples(); start += 127) + { + const int count = juce::jmin(127, captured.getNumSamples() - start); + block.setSize(2, count, false, false, true); + for (int i = 0; i < count; ++i) for (int ch = 0; ch < 2; ++ch) + block.setSample(ch, i, .25f * static_cast(std::sin(juce::MathConstants::twoPi * 220 * (start + i) / rate))); + p.processBlock(block, midi); + for (int ch = 0; ch < 2; ++ch) captured.copyFrom(ch, start, block, ch, 0, count); + for (int i = 0; i < count; ++i) + { + const float value = block.getSample(0, i); finite = finite && std::isfinite(value); + if (start + i > 4800) maxStep = juce::jmax(maxStep, std::abs(static_cast(value - previous))); + previous = value; + } + } + const auto file = folder.getChildFile("motion-character-" + juce::String(character) + ".wav"); + finite = writeProbeWave(file, captured, rate) && finite; + auto* row = new juce::DynamicObject(); row->setProperty("character", character); + row->setProperty("maximumAdjacentStep", maxStep); row->setProperty("file", file.getFullPathName()); + row->setProperty("status", "diagnostic_only"); motion.add(row); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Chorus audit evidence capture"); + result->setProperty("pass", finite); result->setProperty("tailCases", cases); result->setProperty("motion", motion); + result->setProperty("scope", "Capture sanity only; inspect tailContract separately. Ensemble phase reset is source-backed; step size is diagnostic_only."); + return result; +} + +inline juce::var renderOtherPluginsSharedGuitar() +{ + const auto root = juce::File::getCurrentWorkingDirectory(); + const auto source = root.getChildFile("output/reverb-shimmer-performance/shared-guitar/native/baseline-nam-only.wav"); + const auto directory = root.getChildFile(juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_OUTPUT", "output/free-plugin-audit")).getChildFile("guitar"); + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Other free effects on saved guitar project baseline"); + result->setProperty("source", source.getFullPathName()); + juce::WavAudioFormat format; + std::unique_ptr reader(format.createReaderFor(source.createInputStream().release(), true)); + if (!reader) { result->setProperty("pass", false); result->setProperty("error", "Missing previously audited NAM-only guitar source"); return result; } + const double rate = reader->sampleRate; + const int length = static_cast(reader->lengthInSamples); + juce::AudioBuffer input(2, length); + bool valid = reader->read(&input, 0, length, 0, true, true); + valid = writeProbeWave(directory.getChildFile("00-source-nam-only.wav"), input, rate) && valid; + juce::Array cases; + for (const auto& entry : freeSuiteFactories()) + { + const juce::String name = entry.first; + if (name == "Reverb" || name == "Synth" || name == "Piano" || name == "Guitar" || name == "Drums") continue; + for (int variant : {0, 1}) + { + auto p = entry.second(); + if (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_SIGNAL_AUDIT_LEGACY", "") == "1") + { + const auto schema = describeFreePluginForRegression(*p); + if (const auto* params = schema["parameters"].getArray()) for (const auto& param : *params) + if (param["id"].toString() == "signalPath") valid = setFreePluginParamForRegression(*p,"signalPath",0) && valid; + } + const auto set = [&] (const char* id, float value) { valid = setFreePluginParamForRegression(*p, id, value) && valid; }; + if (variant == 1) + { + if (name == "EQ") { set("band0.enabled", 1); set("band0.type", 0); set("band0.freq", 1000); set("band0.gain", 6); set("band0.q", 1); } + else if (name == "Graphic EQ") { set("geq5", 6); } + else if (name == "Compressor") { set("threshold", -24); set("ratio", 4); set("attack", 5); set("release", 100); set("autoMakeup", 0); } + else if (name == "Gate") { set("threshold", -24); set("range", -60); } + else if (name == "Limiter") { set("threshold", -12); set("ceiling", -1); } + else if (name == "Preamp") { set("drive", 12); set("outputGain", -6); } + else if (name == "Saturator") { set("drive", 12); set("mix", .5f); } + else if (name == "Delay") { set("delayTimeL", 250); set("delayTimeR", 375); set("feedback", .45f); set("mix", .5f); } + else if (name == "Chorus") { set("characterMode", 1); set("rate", 1.5f); set("depth", .8f); set("mix", .5f); } + else if (name == "Gain Phase") { set("delayR", 24); set("gain", -3); } + else if (name == "Pitch Correct") { set("transpose", 12); set("mix", 1); } + } + constexpr int blockSize = 512; + p->setRateAndBufferSizeDetails(rate, blockSize); p->prepareToPlay(rate, blockSize); + const int latency = p->getLatencySamples(); + juce::AudioBuffer output(2, length), block(2, blockSize); + juce::MidiBuffer midi; + double peak = 0, energy = 0, difference = 0; int overUnity = 0; + for (int start = 0; start < length + latency; start += blockSize) + { + const int count = juce::jmin(blockSize, length + latency - start); + block.setSize(2, count, false, false, true); block.clear(); + if (start < length) for (int ch = 0; ch < 2; ++ch) + block.copyFrom(ch, 0, input, ch, start, juce::jmin(count, length - start)); + p->processBlock(block, midi); + for (int i = 0; i < count; ++i) + { + const int position = start + i - latency; + if (position < 0 || position >= length) continue; + for (int ch = 0; ch < 2; ++ch) + { + const double value = block.getSample(ch, i); valid = valid && std::isfinite(value); + output.setSample(ch, position, static_cast(value)); + peak = juce::jmax(peak, std::abs(value)); if (std::abs(value) > 1) ++overUnity; + energy += value * value; const double delta = value - input.getSample(ch, position); difference += delta * delta; + } + } + } + const juce::String stem = name.toLowerCase().replace(" ", "-") + (variant == 0 ? "-default" : "-probe"); + const auto file = directory.getChildFile(stem + ".wav"); + valid = writeProbeWave(file, output, rate) && valid; + juce::MemoryBlock state; p->getStateInformation(state); + valid = directory.getChildFile(stem + ".state").replaceWithData(state.getData(), state.getSize()) && valid; + auto* row = new juce::DynamicObject(); row->setProperty("name", entry.first); row->setProperty("variant", variant); + row->setProperty("file", file.getFullPathName()); row->setProperty("rate", rate); row->setProperty("samples", length); + row->setProperty("latencyTrimmed", latency); row->setProperty("peak", peak); row->setProperty("samplesAboveUnity", overUnity); + row->setProperty("rms", std::sqrt(energy / (2.0 * length))); row->setProperty("differenceRms", std::sqrt(difference / (2.0 * length))); + row->setProperty("settings", describeFreePluginForRegression(*p)); row->setProperty("status", "diagnostic_only"); cases.add(row); + } + } + result->setProperty("cases", cases); result->setProperty("pass", valid && cases.size() == 22); + result->setProperty("scope", "Raw unnormalised processor captures; source contains saved NAM processing and two quiet bars. Instruments excluded. Pitch on distorted guitar is a stress diagnostic, not a supported-source quality judgment."); + return result; +} diff --git a/Source/FreePluginPitchRealtimeRegression.h b/Source/FreePluginPitchRealtimeRegression.h new file mode 100644 index 0000000..15170c6 --- /dev/null +++ b/Source/FreePluginPitchRealtimeRegression.h @@ -0,0 +1,358 @@ +#pragma once +#include + +namespace PitchRealtimeRegression +{ +// Retained scalar YIN arithmetic is an independent oracle for the paired-lag +// implementation. The oracle runs a complete frame synchronously and schedules +// its result at the original two-lags-per-retained-sample publication time. +inline std::array originalYIN(const std::array& frame, + double rate, float minimum, float maximum) +{ + std::array yin {}; + yin[0] = 1; + const int low = juce::jmax(1, static_cast(rate / maximum)); + const int high = juce::jmin(1023, static_cast(rate / minimum)); + if (high <= low) return {}; + float running = 0; + for (int lag = 1; lag < 1024; ++lag) + { + float sum = 0; + for (int j = 0; j < 1024; ++j) + { + const float delta = frame[static_cast(j)] - frame[static_cast(j + lag)]; + sum += delta * delta; + } + running += sum; + yin[static_cast(lag)] = running > 0 ? sum * static_cast(lag) / running : 0; + } + int selected = -1; + float best = .15f; + for (int lag = low; lag <= high; ++lag) + if (yin[static_cast(lag)] < best) + { + while (lag + 1 <= high && yin[static_cast(lag + 1)] < yin[static_cast(lag)]) ++lag; + selected = lag; + best = yin[static_cast(lag)]; + break; + } + if (selected < 0) + { + selected = low; + best = yin[static_cast(low)]; + for (int lag = low + 1; lag <= high; ++lag) + if (yin[static_cast(lag)] < best) + { + selected = lag; + best = yin[static_cast(lag)]; + } + } + float refined = static_cast(selected); + if (selected > 0 && selected < 1023) + { + const float a = yin[static_cast(selected - 1)], b = yin[static_cast(selected)]; + const float c = yin[static_cast(selected + 1)], denominator = 2 * (2 * b - c - a); + if (std::abs(denominator) >= 1e-10f) refined += (c - a) / denominator; + } + const float frequency = refined > 0 ? static_cast(rate) / refined : 0; + return frequency < minimum || frequency > maximum ? std::array {} + : std::array { frequency, juce::jlimit(0.0f, 1.0f, 1 - best) }; +} + +struct DetectionEvent +{ + int inputPosition = 0; + std::array result {}; +}; + +inline std::vector originalDetection(const std::vector& input, + double rate, float minimum) +{ + const int decimation = juce::jmax(1, static_cast(std::ceil(rate / 32000.0))); + const double analysisRate = rate / decimation; + const int length = decimation > 1 ? 64 * decimation + 1 : 1; + std::vector coefficients(static_cast(length)), history(static_cast(length)); + const double cutoff = juce::jmin(6000.0, analysisRate * .4) / rate; + double coefficientSum = 0; + for (int i = 0; i < length; ++i) + { + const int offset = i - length / 2; + const double x = 2 * juce::MathConstants::pi * cutoff * offset; + const double sinc = offset == 0 ? 2 * cutoff : std::sin(x) / (juce::MathConstants::pi * offset); + const double window = length == 1 ? 1.0 : .42 - .5 * std::cos(2 * juce::MathConstants::pi * i / (length - 1)) + + .08 * std::cos(4 * juce::MathConstants::pi * i / (length - 1)); + coefficients[static_cast(i)] = static_cast(sinc * window); + coefficientSum += sinc * window; + } + for (auto& coefficient : coefficients) coefficient = static_cast(coefficient / coefficientSum); + std::vector retained; + retained.reserve(input.size() / static_cast(decimation)); + int position = 0, decimationPosition = 0; + for (float sample : input) + { + if (decimation > 1) + { + history[static_cast(position)] = sample; + if (++position == length) position = 0; + if (++decimationPosition < decimation) continue; + decimationPosition = 0; + sample = 0; + int index = position; + for (int tap = 0; tap < length; ++tap) + { + if (--index < 0) index = length - 1; + sample += coefficients[static_cast(tap)] * history[static_cast(index)]; + } + } + retained.push_back(sample); + } + std::vector events; + for (int end = 512; end <= static_cast(retained.size()); end += 512) + { + std::array frame {}; + float squares = 0; + for (int j = 0; j < 2048; ++j) + { + const int index = end - 2048 + j; + if (index >= 0) frame[static_cast(j)] = retained[static_cast(index)]; + squares += frame[static_cast(j)] * frame[static_cast(j)]; + } + const float rms = std::sqrt(squares / 2048.0f); + const float db = rms > 0 ? 20.0f * std::log10(rms) : -100.0f; + if (db < -60) + events.push_back({ end * decimation, {} }); + else + { + const int maximumLag = juce::jmin(1023, static_cast(analysisRate / minimum) + 1); + events.push_back({ (end + (maximumLag + 1) / 2) * decimation, + originalYIN(frame, analysisRate, minimum, 1200) }); + } + } + std::stable_sort(events.begin(), events.end(), [](const auto& a, const auto& b) { return a.inputPosition < b.inputPosition; }); + return events; +} + +inline float inputSample(int position, int channel, double rate) +{ + return static_cast(.18 * std::sin(juce::MathConstants::twoPi * (channel == 0 ? 220 : 329.63) * position / rate) + + .025 * std::sin(.173 * position)); +} + +inline double mapperCacheParity() +{ + PitchMapper mapper; + mapper.prepare(48000); + float previousMidi = 0; + bool hasPrevious = false; + double maximumError = 0; + const std::array speeds { 0, .5f, 20, 200, 50 }; + const std::array durations { 32.0f / 48000, 127.0f / 48000, 127.0f / 48000, 512.0f / 48000 }; + for (int i = 0; i < 600; ++i) + { + if (i == 311) { mapper.reset(); previousMidi = 0; hasPrevious = false; } + const float speed = speeds[static_cast((i / 31) % 5)]; + const float dt = durations[static_cast((i / 7) % 4)]; + const float strength = i % 19 < 7 ? .4f : 1.0f; + const float humanize = i % 23 < 11 ? 72.0f : 0.0f; + const int transpose = i < 230 ? 4 : -3; + const float hz = i % 29 == 0 ? 0 : 440.0f * std::pow(2.0f, (.47f * std::sin(static_cast(i) * .31f)) / 12.0f); + const float confidence = i % 13 < 4 ? .23f : 1.0f; + mapper.setRetuneSpeed(speed); mapper.setCorrectionStrength(strength); + mapper.setHumanize(humanize); mapper.setTranspose(transpose); + float expected = hz; + if (hz > 0) + { + const float inputMidi = 69.0f + 12.0f * std::log2(hz / 440.0f); + float target = std::round(inputMidi) + static_cast(transpose); + target = inputMidi + (target - inputMidi) * strength; + if (humanize > 0) target += (inputMidi - target) * (humanize / 100.0f); + if (!hasPrevious || speed <= .5f) previousMidi = target; + else + { + const float tau = speed / 1000.0f; + const float alpha = juce::jlimit(0.0f, 1.0f, 1.0f - std::exp(-dt / tau)); + previousMidi += (target - previousMidi) * alpha; + } + hasPrevious = true; + if (confidence < .5f) previousMidi = inputMidi + (previousMidi - inputMidi) * (confidence / .5f); + } + if (hasPrevious) expected = 440.0f * std::pow(2.0f, (previousMidi - 69.0f) / 12.0f); + maximumError = juce::jmax(maximumError, std::abs(static_cast(mapper.mapPitch(hz, confidence, dt) - expected))); + } + return maximumError; +} +} + +inline juce::var checkPitchDetectorSchedulingParity() +{ + using namespace PitchRealtimeRegression; + bool pass = true; + juce::Array rows; + for (double rate : { 44100.0, 48000.0, 96000.0, 192000.0 }) + for (float minimum : { 40.0f, 80.0f, 220.0f }) + { + std::vector input(static_cast(rate * .18)); + for (size_t i = 0; i < input.size(); ++i) + input[i] = static_cast(.17 * std::sin(juce::MathConstants::twoPi * 443.7 * static_cast(i) / rate) + + .03 * std::sin(.37 * static_cast(i)) + .02 * std::sin(juce::MathConstants::twoPi * 887.4 * static_cast(i) / rate)); + const auto expected = originalDetection(input, rate, minimum); + double maximumError = 0; + int comparisons = 0; + for (bool variableBlocks : { false, true }) + { + PitchDetector actual; + actual.setRateNormalized(true); + actual.setMinFrequency(minimum); + actual.setMaxFrequency(1200); + actual.prepare(rate, 511); + size_t nextEvent = 0; + std::array latest {}; + const std::array partitions { 1, 17, 64, 127, 511 }; + int block = 0; + for (int position = 0; position < static_cast(input.size());) + { + const int count = juce::jmin(variableBlocks ? partitions[static_cast(block++ % 5)] : 127, + static_cast(input.size()) - position); + actual.processSamples(input.data() + position, count); + position += count; + while (nextEvent < expected.size() && expected[nextEvent].inputPosition <= position) + latest = expected[nextEvent++].result; + maximumError = juce::jmax(maximumError, + std::abs(static_cast(actual.getDetectedFrequency() - latest[0])), + std::abs(static_cast(actual.getConfidence() - latest[1]))); + ++comparisons; + } + } + const bool okay = maximumError == 0; + pass = pass && okay; + auto* row = new juce::DynamicObject(); + row->setProperty("sampleRate", rate); row->setProperty("minimumHz", minimum); + row->setProperty("comparisons", comparisons); row->setProperty("maximumError", maximumError); row->setProperty("pass", okay); + rows.add(row); + } + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Pitch detector arithmetic and publication parity"); + result->setProperty("cases", rows); result->setProperty("pass", pass); result->setProperty("audioQuality", "not_asserted"); + return result; +} + +inline juce::var checkPitchRealtimeContracts() +{ + using namespace PitchRealtimeRegression; + const double mapperError = mapperCacheParity(); + bool pass = mapperError == 0, heapMeasured = false; + int allocations = 0, frees = 0; + juce::Array rows; + for (int signalPath : { 0, 1 }) for (double rate : { 48000.0, 192000.0 }) for (int channels : { 1, 2 }) + { + constexpr int capacity = 127, largeBlock = capacity * 7; + OpenStudioPitchCorrector large, reference, alwaysActive; + for (auto* processor : { &large, &reference, &alwaysActive }) + { + processor->signalPath.store(static_cast(signalPath)); + processor->midiOutputEnabled.store(1); + processor->getMapper().setTranspose(4); + processor->prepareToPlay(rate, capacity); + } + const int latency = large.getLatencySamples(); + bool stableLatency = latency > 0, midiParity = true, finite = true, interiorMidi = false; + bool releasedNote = false, bypassWasAudited = false, resumeWasAudited = false; + double outputError = 0, bypassDryError = 0, resumedWetError = 0; + juce::AudioBuffer audio(channels, largeBlock), expected(channels, largeBlock); + juce::AudioBuffer wetReference(channels, largeBlock); + juce::MidiBuffer actualMidi, expectedMidi, partMidi; + actualMidi.ensureSize(32768); expectedMidi.ensureSize(32768); partMidi.ensureSize(4096); + const int blocks = static_cast(rate * .5) / largeBlock + 1; + for (int frame = 0; frame < blocks; ++frame) + { + // The 10 ms bypass crossfade remains hot, including detector, + // stretcher and MIDI note release. Automation boundaries match. + const bool bypassed = frame >= blocks / 3 && frame < 2 * blocks / 3; + large.bypass.store(bypassed ? 1.0f : 0.0f); + reference.bypass.store(bypassed ? 1.0f : 0.0f); + for (int ch = 0; ch < channels; ++ch) for (int i = 0; i < largeBlock; ++i) + { + const float sample = inputSample(frame * largeBlock + i, ch, rate); + audio.setSample(ch, i, sample); expected.setSample(ch, i, sample); wetReference.setSample(ch, i, sample); + } + actualMidi.clear(); expectedMidi.clear(); +#if JUCE_WINDOWS && defined(_DEBUG) + EQMIDIHeapProbe::allocations = EQMIDIHeapProbe::frees = 0; + EQMIDIHeapProbe::previous = _CrtSetAllocHook(EQMIDIHeapProbe::hook); EQMIDIHeapProbe::active = true; +#endif + large.processBlock(audio, actualMidi); +#if JUCE_WINDOWS && defined(_DEBUG) + EQMIDIHeapProbe::active = false; _CrtSetAllocHook(EQMIDIHeapProbe::previous); + allocations += EQMIDIHeapProbe::allocations; frees += EQMIDIHeapProbe::frees; heapMeasured = true; +#endif + // An independent, continuously active engine proves that bypass + // has not retired its history or changed reactivation latency. + partMidi.clear(); alwaysActive.processBlock(wetReference, partMidi); + for (int offset = 0; offset < largeBlock; offset += capacity) + { + float* pointers[2] { expected.getWritePointer(0, offset), channels > 1 ? expected.getWritePointer(1, offset) : nullptr }; + juce::AudioBuffer part(pointers, channels, capacity); + partMidi.clear(); reference.processBlock(part, partMidi); + expectedMidi.addEvents(partMidi, 0, capacity, offset); + } + auto a = actualMidi.begin(), b = expectedMidi.begin(); + while (a != actualMidi.end() && b != expectedMidi.end()) + { + const auto actualEvent = *a, expectedEvent = *b; + midiParity = midiParity && actualEvent.samplePosition == expectedEvent.samplePosition + && actualEvent.numBytes == expectedEvent.numBytes + && std::memcmp(actualEvent.data, expectedEvent.data, static_cast(actualEvent.numBytes)) == 0; + interiorMidi = interiorMidi || actualEvent.samplePosition > 0; + releasedNote = releasedNote || (bypassed && actualEvent.getMessage().isNoteOff()); + ++a; ++b; + } + midiParity = midiParity && a == actualMidi.end() && b == expectedMidi.end(); + for (int ch = 0; ch < channels; ++ch) for (int i = 0; i < largeBlock; ++i) + { + finite = finite && std::isfinite(audio.getSample(ch, i)); + outputError = juce::jmax(outputError, std::abs(static_cast(audio.getSample(ch, i) - expected.getSample(ch, i)))); + const int position = frame * largeBlock + i; + const int rampSamples = static_cast(rate * .010) + 1; + if (bypassed && position >= (blocks / 3) * largeBlock + rampSamples) + { + const float dry = position < latency ? 0 : inputSample(position - latency, ch, rate); + bypassDryError = juce::jmax(bypassDryError, std::abs(static_cast(audio.getSample(ch, i) - dry))); + bypassWasAudited = true; + } + if (position >= (2 * blocks / 3) * largeBlock + rampSamples) + { + resumedWetError = juce::jmax(resumedWetError, std::abs(static_cast(audio.getSample(ch, i) - wetReference.getSample(ch, i)))); + resumeWasAudited = true; + } + } + stableLatency = stableLatency && large.getLatencySamples() == latency; + } + // Extra channels are cleared, and an unprepared direct call is silent. + juce::AudioBuffer invalid(4, 19); + for (int ch = 0; ch < 4; ++ch) for (int i = 0; i < 19; ++i) invalid.setSample(ch, i, .1f); + actualMidi.clear(); large.processBlock(invalid, actualMidi); + const bool extraChannels = invalid.getMagnitude(2, 0, 19) == 0 && invalid.getMagnitude(3, 0, 19) == 0; + OpenStudioPitchCorrector unprepared; + unprepared.processBlock(invalid, actualMidi); + const bool preparationGuard = invalid.getMagnitude(0, 19) == 0; + const bool okay = finite && outputError == 0 && stableLatency && midiParity && interiorMidi && extraChannels && preparationGuard + && releasedNote && bypassWasAudited && resumeWasAudited && bypassDryError == 0 && resumedWetError == 0; + pass = pass && okay; + auto* row = new juce::DynamicObject(); + row->setProperty("signalPath", signalPath); row->setProperty("sampleRate", rate); row->setProperty("channels", channels); + row->setProperty("outputError", outputError); row->setProperty("stableLatency", stableLatency); + row->setProperty("midiParity", midiParity); row->setProperty("interiorMidi", interiorMidi); + row->setProperty("bypassReleasedNote", releasedNote); row->setProperty("bypassDryError", bypassDryError); + row->setProperty("resumedWetError", resumedWetError); + row->setProperty("extraChannelsCleared", extraChannels); row->setProperty("unpreparedGuard", preparationGuard); row->setProperty("pass", okay); + rows.add(row); + } + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Pitch bounded blocks, bypass, MIDI and latency"); + result->setProperty("mapperCacheMaximumError", mapperError); + result->setProperty("cases", rows); result->setProperty("callbackHeapMeasured", heapMeasured); + result->setProperty("callbackAllocations", allocations); result->setProperty("callbackFrees", frees); + result->setProperty("pass", pass && allocations == 0 && frees == 0); + result->setProperty("audioQuality", "not_asserted"); + return result; +} diff --git a/Source/FreePluginRegression.cpp b/Source/FreePluginRegression.cpp index 3ca1bb2..6ea8e6a 100644 --- a/Source/FreePluginRegression.cpp +++ b/Source/FreePluginRegression.cpp @@ -8,6 +8,7 @@ namespace juce { class AudioProcessor; bool openStudioVST3AutomationQueueSelfTes #include "BuiltInEffects.h" #include "BuiltInEQMatch.h" #include "BuiltInEffects2.h" +#include "FreePluginReverbCompatibilityRegression.h" #include "BuiltInUtilityEffects.h" #include "OpenStudioPitchCorrector.h" #include "BuiltInInstrumentPreview.h" @@ -45,6 +46,25 @@ bool writeProbeWave(const juce::File& file, const juce::AudioBuffer& audi } #include "FreePluginTenHourListening.h" +#include "FreePluginReverbAudit.h" +#include "FreePluginReverbOutputRegression.h" +#include "FreePluginReverbRetirementRegression.h" +#include "FreePluginReverbFactoryRegression.h" +#include "FreePluginReverbRefinementRegression.h" +#include "FreePluginReverbListening.h" +#include "FreePluginReverbBalance.h" +#include "FreePluginReverbReturnRegression.h" +#include "FreePluginReverbReturnIRRegression.h" +#include "FreePluginReverbControlRegression.h" +#include "FreePluginReverbCorrectionRegression.h" +#include "FreePluginReverbVoiceOptionsRegression.h" +#include "FreePluginShimmerSchedulingRegression.h" +#include "FreePluginShimmerPerformanceRegression.h" +#include "FreePluginReverbExtendedRegression.h" +#include "FreePluginReverbVoicedBalance.h" +#include "FreePluginReverbSharedGuitarProject.h" +#include "FreePluginReverbSparseDiffusionRegression.h" +#include "FreePluginSettledReverbRegression.h" #include "FreePluginMultiOutputRegression.h" #include "FreePluginInstrumentTailRegression.h" #include "FreePluginInstrumentPerformanceRegression.h" @@ -55,6 +75,11 @@ bool writeProbeWave(const juce::File& file, const juce::AudioBuffer& audi #include "FreePluginGateOnsetRegression.h" #include "FreePluginHumanizeRegression.h" #include "FreePluginPerformanceRegression.h" +#include "FreePluginOtherSignalAudit.h" +#include "FreePluginSignalRepairRegression.h" +#include "FreePluginDynamicsAudit.h" +#include "FreePluginEQPhasePitchAudit.h" +#include "FreePluginInstrumentAudit.h" #include "FreePluginAutomationRegistryRegression.h" #include "FreePluginVendorRenderDiagnostic.h" #include "FreePluginStressPerformance.h" @@ -127,6 +152,8 @@ bool writeProbeWave(const juce::File& file, const juce::AudioBuffer& audi #include "FreePluginEQPreparedProgramsRegression.h" #include "FreePluginContinuousAlignmentRegression.h" #include "FreePluginPitchCostRegression.h" +#include "FreePluginPitchRealtimeRegression.h" +#include "FreePluginSynthWorkRegression.h" #include "FreePluginReverbResponseRegression.h" #include "FreePluginEQDraftRegression.h" #include "FreePluginSostenutoRegression.h" @@ -2668,22 +2695,33 @@ juce::var checkIRAudition() auto limited=renderBuiltInIRAudition(source,48000,0,0,{}); const bool peakCap=limited.error.isEmpty()&&limited.attenuationDb<0&&limited.audio.getMagnitude(0,limited.audio.getNumSamples())<=.250001f; auto owner=std::make_shared();BuiltInIRPreview preview; + const bool initiallyIdle = !preview.mayRender(); const auto stale=preview.begin("old"),ticket=preview.begin("new"); + const bool preparationIdle = !preview.mayRender(); juce::AudioBuffer staleAudio(2,2000);staleAudio.clear(); const bool staleRejected=!preview.publish(stale,std::move(staleAudio),48000,owner); juce::AudioBuffer audio(2,2000);for(int ch=0;ch<2;++ch)for(int i=0;i<2000;++i)audio.setSample(ch,i,.1f); - const bool published=preview.publish(ticket,std::move(audio),48000,owner);preview.stop("old"); + const bool published=preview.publish(ticket,std::move(audio),48000,owner); + const bool publicationVisible = preview.mayRender(); + preview.stop("old"); juce::AudioBuffer output(2,512);output.clear();preview.render(output.getArrayOfWritePointers(),2,512,48000); const bool otherOwnerCannotStop=output.getMagnitude(0,512)==.1f; - preview.stop("new");output.clear();preview.render(output.getArrayOfWritePointers(),2,512,48000); + preview.stop("new"); + const bool stopRampVisible = preview.mayRender(); + output.clear();preview.render(output.getArrayOfWritePointers(),2,512,48000); output.clear();preview.render(output.getArrayOfWritePointers(),2,512,48000);const bool stopped=output.getMagnitude(0,512)==0; + const bool retiredIdle = !preview.mayRender(); const auto removedTicket=preview.begin("removed");juce::AudioBuffer removedAudio(2,2000);removedAudio.clear();removedAudio.applyGain(1); - preview.publish(removedTicket,std::move(removedAudio),48000,owner);owner.reset();output.clear();preview.render(output.getArrayOfWritePointers(),2,512,48000); + const bool republished = preview.publish(removedTicket,std::move(removedAudio),48000,owner) && preview.mayRender(); + owner.reset();output.clear();preview.render(output.getArrayOfWritePointers(),2,512,48000); const bool removalStops=!static_cast(preview.status("removed")["playing"]); - passed=passed&&finite&&bounded&&channelRouting&&unchanged&&cancelledClean&&peakCap&&staleRejected&&published&&otherOwnerCannotStop&&stopped&&removalStops; + const bool activityLifecycle = initiallyIdle && preparationIdle && publicationVisible + && stopRampVisible && retiredIdle && republished && !preview.mayRender(); + passed=passed&&finite&&bounded&&channelRouting&&unchanged&&cancelledClean&&peakCap&&staleRejected&&published&&otherOwnerCannotStop&&stopped&&removalStops&&activityLifecycle; result->setProperty("pass",passed);result->setProperty("cases",cases);result->setProperty("impulseError",impulseError); result->setProperty("sourceUnchanged",unchanged);result->setProperty("cancelledClean",cancelledClean);result->setProperty("peakCap",peakCap); result->setProperty("staleRejected",staleRejected);result->setProperty("ownerStop",otherOwnerCannotStop&&stopped);result->setProperty("removalStops",removalStops); + result->setProperty("activityLifecycle", activityLifecycle); result->setProperty("audioQuality","not_asserted");return result; } @@ -2692,7 +2730,15 @@ juce::var checkPreviewOwnership() auto source = std::make_shared(); source->prepareToPlay(48000, 256); BuiltInInstrumentPreview preview; const auto make = [] { return std::make_unique(); }; - bool passed = preview.send("one", source, 60, true, 48000, make) && preview.send("two", source, 60, true, 48000, make); + const bool initiallyIdle = !preview.mayRender(); + bool preparingIdle = false; + const auto firstMake = [&] + { + preparingIdle = !preview.mayRender(); + return make(); + }; + bool passed = preview.send("one", source, 60, true, 48000, firstMake) + && preview.mayRender() && preview.send("two", source, 60, true, 48000, make); juce::AudioBuffer buffer(2, 256), original(2, 256); juce::MidiBuffer midi; double previewEnergy = 0, sourceEnergy = 0; for (int i = 0; i < 30; ++i) @@ -2704,8 +2750,17 @@ juce::var checkPreviewOwnership() for (int i = 0; i < 5; ++i) { buffer.clear(); preview.render(buffer.getArrayOfWritePointers(), 2, 256, 48000); } const bool secondSurvives = buffer.getMagnitude(0, 256) > 0; preview.stopAll(); + const bool stopRampVisible = preview.mayRender(); for (int i = 0; i < 5; ++i) { buffer.clear(); preview.render(buffer.getArrayOfWritePointers(), 2, 256, 48000); } const bool stopped = buffer.getMagnitude(0, 256) == 0; + bool activityLifecycle = initiallyIdle && preparingIdle && stopRampVisible && !preview.mayRender(); + activityLifecycle = preview.send("reopened", source, 67, true, 48000, make) + && preview.mayRender() && activityLifecycle; + preview.send("reopened", source, 67, false, 48000, make); + const bool releaseTailVisible = preview.mayRender(); + preview.stopAll(); + for (int i = 0; i < 5; ++i) { buffer.clear(); preview.render(buffer.getArrayOfWritePointers(), 2, 256, 48000); } + activityLifecycle = activityLifecycle && releaseTailVisible && !preview.mayRender(); bool bindingsMatch = true, previewRoutesIsolated = true; const auto checkBindings = [&bindingsMatch,&previewRoutesIsolated](const std::shared_ptr& controlSource, std::unique_ptr clone) { @@ -2729,9 +2784,162 @@ juce::var checkPreviewOwnership() checkBindings(std::make_shared(), std::make_unique()); checkBindings(std::make_shared(), std::make_unique()); passed = passed && previewEnergy > 0 && sourceEnergy == 0 && secondSurvives && stopped; - passed = passed && bindingsMatch && previewRoutesIsolated; + passed = passed && bindingsMatch && previewRoutesIsolated && activityLifecycle; auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Isolated preview leases"); result->setProperty("pass", passed); - result->setProperty("sourceRemainsSilent", sourceEnergy == 0); result->setProperty("otherEditorSurvivesStop", secondSurvives); result->setProperty("panicClears", stopped); result->setProperty("liveParameterBindings", bindingsMatch);result->setProperty("previewRoutesStayStereo",previewRoutesIsolated); return result; + result->setProperty("sourceRemainsSilent", sourceEnergy == 0); result->setProperty("otherEditorSurvivesStop", secondSurvives); result->setProperty("panicClears", stopped); result->setProperty("liveParameterBindings", bindingsMatch);result->setProperty("previewRoutesStayStereo",previewRoutesIsolated); + result->setProperty("activityLifecycle", activityLifecycle); + return result; +} + +juce::var checkPreviewCallbackBounds() +{ + constexpr double rate = 48000.0; + constexpr int capacity = 512; + constexpr float poison = 0.375f; + const std::array blockSizes { 5, 8, 16, 127, capacity }; + const auto poisonBuffer = [poison] (juce::AudioBuffer& buffer) + { + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + juce::FloatVectorOperations::fill(buffer.getWritePointer(channel), poison, buffer.getNumSamples()); + }; + const auto suffixUntouched = [poison] (const juce::AudioBuffer& buffer, int start) + { + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + for (int sample = start; sample < buffer.getNumSamples(); ++sample) + if (buffer.getSample(channel, sample) != poison) + return false; + return true; + }; + PlaybackEngine::PitchScrubPreviewData scrub; + scrub.trackId = "preview-bounds"; + scrub.clipId = "preview-bounds-loop"; + scrub.sourceSampleRate = rate; + scrub.loopBuffer.setSize(2, 4096); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < scrub.loopBuffer.getNumSamples(); ++sample) + scrub.loopBuffer.setSample(channel, sample, .1f * std::sin( + juce::MathConstants::twoPi * static_cast(sample) / (channel == 0 ? 97.0f : 131.0f))); + + PlaybackEngine bounded, compact; + bounded.setPitchScrubPreview(scrub); + compact.setPitchScrubPreview(scrub); + juce::AudioBuffer scratch(2, capacity); + bool scratchSuffixPreserved = true, exactScrubOutput = true; + juce::int64 expectedSamples = 0; + for (int repeat = 0; repeat < 16; ++repeat) + for (const int length : blockSizes) + { + poisonBuffer(scratch); + scratch.clear(0, length); + juce::AudioBuffer reference(2, length); + reference.clear(); + bounded.renderPitchScrubPreview(scratch, rate, length); + compact.renderPitchScrubPreview(reference, rate); + for (int channel = 0; channel < 2; ++channel) + exactScrubOutput = exactScrubOutput && std::memcmp(scratch.getReadPointer(channel), + reference.getReadPointer(channel), static_cast(length) * sizeof(float)) == 0; + scratchSuffixPreserved = scratchSuffixPreserved && suffixUntouched(scratch, length); + expectedSamples += length; + } + const bool exactScrubClock = bounded.getPitchScrubPreviewStatus(scrub.clipId).mixedSampleCount == expectedSamples; + poisonBuffer(scratch); + bounded.renderPitchScrubPreview(scratch, rate, 0); + const bool zeroLengthNoOp = suffixUntouched(scratch, 0) + && bounded.getPitchScrubPreviewStatus(scrub.clipId).mixedSampleCount == expectedSamples; + scratch.clear(); + bounded.renderPitchScrubPreview(scratch, rate, capacity + 100); + const bool oversizedLengthClamped = bounded.getPitchScrubPreviewStatus(scrub.clipId).mixedSampleCount == expectedSamples + capacity; + bounded.clearPitchScrubPreview(scrub.clipId); + const bool scrubTailVisible = bounded.mayRenderPitchScrubPreview(); + for (int block = 0; block < 256 && bounded.mayRenderPitchScrubPreview(); ++block) + { + scratch.clear(0, 8); + bounded.renderPitchScrubPreview(scratch, rate, 8); + } + const bool scrubRetired = scrubTailVisible && !bounded.mayRenderPitchScrubPreview(); + + auto owner = std::make_shared(); + owner->prepareToPlay(rate, capacity); + BuiltInInstrumentPreview instrument; + BuiltInIRPreview ir; + bool previewSuffixPreserved = instrument.send("bounds", owner, 60, true, rate, + [] { return std::make_unique(); }); + juce::AudioBuffer irAudio(2, 8192); + for (int channel = 0; channel < 2; ++channel) + juce::FloatVectorOperations::fill(irAudio.getWritePointer(channel), .1f, irAudio.getNumSamples()); + previewSuffixPreserved = ir.publish(ir.begin("bounds"), std::move(irAudio), rate, owner) && previewSuffixPreserved; + for (const int length : blockSizes) + { + poisonBuffer(scratch); scratch.clear(0, length); + if (instrument.mayRender()) instrument.render(scratch.getArrayOfWritePointers(), 2, length, rate); + previewSuffixPreserved = previewSuffixPreserved && suffixUntouched(scratch, length); + poisonBuffer(scratch); scratch.clear(0, length); + if (ir.mayRender()) ir.render(scratch.getArrayOfWritePointers(), 2, length, rate); + previewSuffixPreserved = previewSuffixPreserved && suffixUntouched(scratch, length); + } + + // Prepare the real host callback with a fake device. This never opens an + // interface; its smaller blocks must advance scrub time by their length, + // not by the 512-sample scratch capacity prepared here. + class PreviewDevice final : public juce::AudioIODevice + { + public: + PreviewDevice() : AudioIODevice("Preview callback fixture", "Offline fixture") {} + juce::StringArray getOutputChannelNames() override { return { "Left", "Right" }; } + juce::StringArray getInputChannelNames() override { return {}; } + juce::Array getAvailableSampleRates() override { return { 48000.0 }; } + juce::Array getAvailableBufferSizes() override { return { 512 }; } + int getDefaultBufferSize() override { return 512; } + juce::String open(const juce::BigInteger&, const juce::BigInteger&, double, int) override { return "Offline fixture cannot open hardware"; } + void close() override {} + bool isOpen() override { return false; } + void start(juce::AudioIODeviceCallback*) override {} + void stop() override {} + bool isPlaying() override { return false; } + juce::String getLastError() override { return {}; } + int getCurrentBufferSizeSamples() override { return 512; } + double getCurrentSampleRate() override { return 48000.0; } + int getCurrentBitDepth() override { return 32; } + juce::BigInteger getActiveOutputChannels() const override { return juce::BigInteger(3); } + juce::BigInteger getActiveInputChannels() const override { return {}; } + int getOutputLatencyInSamples() override { return 0; } + int getInputLatencyInSamples() override { return 0; } + } device; + auto engine = std::make_unique(AudioEngine::DeviceStartup::offline); + engine->audioDeviceAboutToStart(&device); + engine->getPlaybackEngine().setPitchScrubPreview(scrub); + juce::int64 hostExpectedSamples = 0; + bool hostSuffixPreserved = true, hostSampleClock = true; + for (int repeat = 0; repeat < 8; ++repeat) + for (const int length : blockSizes) + { + poisonBuffer(scratch); + engine->audioDeviceIOCallbackWithContext(nullptr, 0, + scratch.getArrayOfWritePointers(), 2, length, {}); + hostExpectedSamples += length; + hostSuffixPreserved = hostSuffixPreserved && suffixUntouched(scratch, length); + hostSampleClock = hostSampleClock + && engine->getPlaybackEngine().getPitchScrubPreviewStatus(scrub.clipId).mixedSampleCount == hostExpectedSamples; + } + const bool hardwareUnopened = engine->getDeviceManager().getCurrentAudioDevice() == nullptr; + engine->audioDeviceStopped(); + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Preview active callback bounds"); + result->setProperty("pass", scratchSuffixPreserved && exactScrubOutput && exactScrubClock + && zeroLengthNoOp && oversizedLengthClamped && scrubRetired && previewSuffixPreserved + && hostSuffixPreserved && hostSampleClock && hardwareUnopened); + result->setProperty("scratchSuffixPreserved", scratchSuffixPreserved); + result->setProperty("exactCompactScrubParity", exactScrubOutput && exactScrubClock); + result->setProperty("zeroLengthNoOp", zeroLengthNoOp); + result->setProperty("oversizedLengthClamped", oversizedLengthClamped); + result->setProperty("scrubReleaseRetires", scrubRetired); + result->setProperty("instrumentAndIRSuffixPreserved", previewSuffixPreserved); + result->setProperty("hostSuffixPreserved", hostSuffixPreserved); + result->setProperty("hostSampleClock", hostSampleClock); + result->setProperty("hostMixedSamples", hostExpectedSamples); + result->setProperty("hardwareUnopened", hardwareUnopened); + result->setProperty("audioQuality", "not_asserted"); + return result; } juce::var checkModelMemoryAndHost() @@ -3894,12 +4102,160 @@ juce::var checkPitchTelemetryPublication() } } +namespace +{ +juce::var checkFreeSignalRepairs() +{ + bool passed = true; + juce::Array checks; + for (const auto& result : {checkFreeSignalCompatibility(), checkSignalPoleAndMIDICompatibility(), + checkFreePitchRangeRepair(), checkFreeSignalTransitions(), checkDrumReleaseRepair(), checkSignalPitchFFTParity()}) + { + passed = passed && static_cast(result["pass"]); + checks.add(result); + } + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Free-plugin deterministic signal repairs"); + result->setProperty("pass", passed); + result->setProperty("checks", checks); + result->setProperty("audioQuality", "not_asserted"); + return juce::var(result); +} + +juce::var auditOtherSuiteCallbackHeap() +{ + bool pass = true, measured = false; juce::Array rows; + for (const auto& entry : freeSuiteFactories()) + { + if (juce::String(entry.first) == "Reverb") continue; + for (double rate : {48000.0, 96000.0}) + { + auto p = entry.second(); constexpr int blockSize = 128; + p->setRateAndBufferSizeDetails(rate, blockSize); p->prepareToPlay(rate, blockSize); + const auto schema = describeFreePluginForRegression(*p); + juce::AudioBuffer audio(juce::jmax(2, p->getTotalNumOutputChannels()), blockSize); + juce::MidiBuffer midi; midi.ensureSize(8192); int allocations = 0, frees = 0; + juce::Array heapEvents; + bool finite = true, controls = true; + for (int frame = 0; frame < 96; ++frame) + { + if (frame == 32 || frame == 64) + if (const auto* parameters = schema["parameters"].getArray()) + for (const auto& parameter : *parameters) + if (parameter["automatable"] && parameter["type"].toString() != "meter") + controls = setFreePluginParamForRegression(*p, parameter["id"].toString(), + static_cast(static_cast(parameter[frame == 32 ? "max" : "min"]))) && controls; + audio.clear(); midi.clear(); + for (int i = 0; i < blockSize; ++i) for (int ch = 0; ch < 2; ++ch) + audio.setSample(ch, i, .05f * std::sin(.1f * static_cast(frame * blockSize + i))); + if (p->acceptsMidi() && frame % 32 == 0) + for (int note : {36, 48, 60, 69}) midi.addEvent(juce::MidiMessage::noteOn(1, note, .7f), 0); +#if JUCE_WINDOWS && defined(_DEBUG) + EQMIDIHeapProbe::allocations = EQMIDIHeapProbe::frees = 0; + EQMIDIHeapProbe::previous = _CrtSetAllocHook(EQMIDIHeapProbe::hook); EQMIDIHeapProbe::active = true; +#endif + p->processBlock(audio, midi); +#if JUCE_WINDOWS && defined(_DEBUG) + EQMIDIHeapProbe::active = false; _CrtSetAllocHook(EQMIDIHeapProbe::previous); + allocations += EQMIDIHeapProbe::allocations; frees += EQMIDIHeapProbe::frees; measured = true; + if (EQMIDIHeapProbe::allocations != 0 || EQMIDIHeapProbe::frees != 0) + { + auto* event = new juce::DynamicObject(); event->setProperty("frame", frame); + event->setProperty("allocations", EQMIDIHeapProbe::allocations); event->setProperty("frees", EQMIDIHeapProbe::frees); + heapEvents.add(event); + } +#endif + for (int ch = 0; ch < audio.getNumChannels(); ++ch) for (int i = 0; i < blockSize; ++i) + finite = finite && std::isfinite(audio.getSample(ch, i)); + } + auto* row = new juce::DynamicObject(); row->setProperty("plugin", entry.first); row->setProperty("rate", rate); + row->setProperty("allocations", allocations); row->setProperty("frees", frees); row->setProperty("finite", finite); + row->setProperty("heapEvents", heapEvents); + row->setProperty("controlsSet", controls); row->setProperty("pass", finite && controls && allocations == 0 && frees == 0); + pass = pass && static_cast(row->getProperty("pass")); rows.add(row); + } + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Non-Reverb callback CRT heap audit"); + result->setProperty("pass", pass && measured); result->setProperty("heapMeasured", measured); result->setProperty("cases", rows); + result->setProperty("scope", "Windows Debug CRT only, callback thread allocations/frees at startup and exposed automatable extrema. Setter/prepare allocations excluded; not a complete allocator or race proof."); + return result; +} + +juce::var auditOtherSuiteBaseline(const juce::File& fixtures) +{ + bool passed = true; juce::Array rows; std::vector factories; + for (const auto& entry : freeSuiteFactories()) + { + if (juce::String(entry.first) == "Reverb") continue; + factories.push_back(entry.second); + auto processor = entry.second(); + const bool utility = dynamic_cast(processor.get()) != nullptr; + const auto row = checkProcessor(entry.second, utility ? juce::File{} : fixtures, false); + passed = passed && static_cast(row["pass"]); rows.add(row); + } + const auto matrix = checkSuiteControlMatrix(factories); + passed = passed && static_cast(matrix["pass"]); rows.add(matrix); + for (const auto& row : {checkPreampTone(), checkPreampReferenceAndMeters(), checkOpticalWorkflow(), + checkVCAWorkflow(), checkFETWorkflow(), checkSaturationColour(), checkUtilityAndLimiterContracts(), + checkLimiterGainWorkflow(), checkGraphicEQWorkflow(), checkManualPhaseAlignment(), checkExpandedEQ(), + checkEQMatching(), checkSynthFilterModulation(), checkSynthIndependentEnvelope(), checkSynthModulationMatrix(), + checkPianoExpressivePerformance(), checkCompressorAverageMeter(), checkOutputMeters(), + checkStandaloneDelayModes(), checkChorusSyncAndModes(), checkDynamicsAndPitchTiming(), checkMonoPitchSafety(), + checkEQEditorContract(), checkPitchTelemetryPublication(), checkLowRateChannelSafety(), checkFreePluginAutomationRanges()}) + { passed = passed && static_cast(row["pass"]); rows.add(row); } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Other free-plugin baseline contracts"); + result->setProperty("pass", passed); result->setProperty("checks", rows); + result->setProperty("scope", "15 non-Reverb processors, existing deterministic contracts; low-rate and automation checks also retain existing shared-suite coverage. Sound quality not_asserted."); + return result; +} +} + juce::var runFreePluginRegression(const juce::File& fixtureDirectory, bool captureMissingFixtures, const juce::String& selectedCase) { juce::Array results; if (selectedCase.isNotEmpty()) { const std::map> focused { + {"preview-callback-bounds", checkPreviewCallbackBounds}, + {"ir-audition", checkIRAudition}, + {"pitch-detector-parity", checkPitchDetectorSchedulingParity}, + {"pitch-realtime-contracts", checkPitchRealtimeContracts}, + {"synth-cached-work", checkSynthCachedWorkTransitions}, + {"signal-repairs", checkFreeSignalRepairs}, + {"audit-other-baseline", [&] { return auditOtherSuiteBaseline(fixtureDirectory); }}, + {"audit-other-heap", auditOtherSuiteCallbackHeap}, + {"signal-compatibility", checkFreeSignalCompatibility}, {"signal-poles-midi", checkSignalPoleAndMIDICompatibility}, {"signal-pitch-range", checkFreePitchRangeRepair}, {"signal-transitions", checkFreeSignalTransitions}, {"signal-drum-release", checkDrumReleaseRepair}, {"signal-pitch-performance", profileSignalPitchCost}, {"signal-pitch-fft-parity", checkSignalPitchFFTParity}, + {"audit-modulation", auditChorusSignalContracts}, + {"audit-shared-guitar", renderOtherPluginsSharedGuitar}, + {"dynamics-audit", auditFreePluginDynamics}, + {"audit-eq-phase-pitch", auditEQPhasePitchContracts}, + {"instrument-audit", auditFreeInstrumentSignals}, + {"reverb-audit", [] { return auditStandaloneReverb(); }}, + {"reverb-audit-calibrated", [] { return auditStandaloneReverb(true); }}, + {"reverb-return-level", checkReverbReturnCalibration}, + {"reverb-corrected-controls", checkReverbControlBehavior}, + {"reverb-correction-responses", checkReverbCorrectionResponses}, + {"reverb-voice-options", checkReverbVoiceOptions}, + {"reverb-shimmer-scheduling", checkShimmerScheduling}, + {"reverb-shimmer-performance", checkShimmerPerformanceBehavior}, + {"reverb-shimmer-instances", profileShimmerSchedulingInstances}, + {"reverb-extended", checkReverbExtendedQualification}, + {"reverb-extended-nonlinear-tail", checkReverbExtendedNonlinearTail}, + {"reverb-extended-hold-headroom", checkReverbExtendedHoldHeadroom}, + {"reverb-extended-heap", checkReverbExtendedHeapTransitions}, + {"reverb-return-ir-crossfade", checkReverbReturnIRCrossfade}, + {"reverb-balance", renderReverbBalancePack}, + {"reverb-voiced-balance", renderReverbVoicedBalancePack}, + {"reverb-shared-guitar-project", renderReverbSharedGuitarProject}, + {"reverb-output", checkReverbOutputGain}, + {"reverb-retirement", checkReverbRetirement}, + {"reverb-factory", checkReverbFactoryPresets}, + {"reverb-refinement-state", checkReverbRefinementState}, + {"reverb-listening", renderReverbListeningPack}, + {"reverb-instances", profileReverbInstances}, + {"reverb-sparse-diffusion", checkReverbSparseDiffusion}, + {"reverb-settled-taps", checkSettledReverbInterpolation}, + {"reverb-compatibility-sleep", checkReverbCompatibilitySleep}, {"plugin-discovery-identities", PluginManager::runDiscoveryIdentityRegression}, {"low-rate-channel-safety", checkLowRateChannelSafety}, {"automation-registry", checkFreePluginAutomationRegistry}, @@ -3930,6 +4286,7 @@ juce::var runFreePluginRegression(const juce::File& fixtureDirectory, bool captu if (found == focused.end()) root->setProperty("error", "Unknown regression case"); return root; } + results.add(checkPreviewCallbackBounds()); const std::vector factories { factory(), [] { return std::make_unique(true); }, [] { return std::make_unique(true); }, [] { return std::make_unique(true); }, [] { return std::make_unique(24.1f,true); }, [] { return std::make_unique(true); }, @@ -3937,7 +4294,7 @@ juce::var runFreePluginRegression(const juce::File& fixtureDirectory, bool captu factory(), factory(), factory(), factory() }; - bool passed = true; + bool passed = static_cast(results[0]["pass"]); for (const auto& make : factories) { auto result = checkProcessor(make, fixtureDirectory, captureMissingFixtures); @@ -3969,6 +4326,14 @@ juce::var runFreePluginRegression(const juce::File& fixtureDirectory, bool captu const auto dynamicsTiming = checkDynamicsAndPitchTiming(); passed = passed && static_cast(dynamicsTiming["pass"]); results.add(dynamicsTiming); + const auto synthWork = checkSynthCachedWorkTransitions(); + passed = passed && static_cast(synthWork["pass"]); + results.add(synthWork); + for (const auto& pitchResult : { checkPitchDetectorSchedulingParity(), checkPitchRealtimeContracts() }) + { + passed = passed && static_cast(pitchResult["pass"]); + results.add(pitchResult); + } for (const auto kind : { OpenStudioUtilityEffect::Kind::Preamp, OpenStudioUtilityEffect::Kind::GraphicEQ, OpenStudioUtilityEffect::Kind::GainPhase }) { const auto result = checkProcessor([kind] { return std::make_unique(kind); }, {}, false); @@ -3981,7 +4346,16 @@ juce::var runFreePluginRegression(const juce::File& fixtureDirectory, bool captu const auto reverbBanks = checkReverbTypeState(); passed = passed && static_cast(reverbBanks["pass"]); results.add(reverbBanks); - for (const auto& result : { checkGuitarPerformanceTelemetry(), checkOriginalColourSpectra(), checkOriginalColourStages(), checkDelayTimingTelemetry(), checkInstrumentPerformanceTelemetry(), checkSustainedHumanize(), checkGateOnsetQualification(), checkInstrumentReleaseTails(), checkConvolutionOutputs(), checkDrumMultiOutputs(), checkReconstructedLimiter(), checkInstrumentArticulation(), checkAdditionalReverbs(), checkCreativeReverbControls(), checkCreativeRoutingAndLateDecay(), checkReverbHoldPolicies(), checkReverbSpillover(), checkFreePluginAutomationRanges(), checkConvolutionMotion(), checkStudioPlate(), checkStudioSpaces(), checkVintageSpaces(), checkVintageBassDecay(), checkVintageConversion(), checkVintageTankRate(), checkLongReverbPredelay(), checkModalPlate(), checkSpatialLongDelay(), checkIRBrightness(), checkIRSourceBlend(), checkPreampTone(), checkPreampReferenceAndMeters(), checkFETWorkflow(), checkFETDetectorTilt(), checkFETMultiButtons(), checkVCANoiseMonitor(), checkNonlinearMotion(), checkOpticalWorkflow(), checkVCAWorkflow(), checkGraphicEQWorkflow(), checkManualPhaseAlignment(), checkSpectralPhaseAlignment(), checkExpandedEQ(), checkLinearPhaseEQ(), checkEQBandDetectors(), checkAdvancedEQCuts(), checkMinimumPhaseEQ(), checkAnalogTargetEQ(), checkEQDraftProcessingModes(), checkEQAdaptiveDynamics(), checkSpectralEQ(), checkLinearBandDynamics(), checkExtendedEQShapes(), checkLimiterOversampling(), checkIntegratedLoudness(), checkLimiterResponses(), checkLimiterStrategies(), checkLimiterGainWorkflow(), checkSaturationColour(), checkSpatialReverbs(), checkPlateColour(), checkEchoRooms(), checkAmbientSpaces(), checkDispersiveSpring(), checkSpringHold(), checkMagneticHold(), checkNonlinearHold(), checkPositionedHold(), checkEQMIDIPrograms(), checkEQPreparedPrograms(), checkContinuousAlignment(), checkPitchBoundedLagParity(), checkAutomaticAlignment(), checkLinkedStereoAlignment(), checkAllPassFitting(), checkAlignmentSections(), checkSparseAlignment(), checkProjectSpanAlignment(), checkAlignmentGroups(), checkEQMatching(), checkGateExpansion(), checkStandaloneDelayModes(), checkDelayDiffusion(), checkSynthFilterModulation(), checkSynthIndependentEnvelope(), checkSynthModulationMatrix(), checkSynthMPE(), checkSynthOscillatorShapes(), checkExpandedSynthMatrix(), checkSynthDestinations(), checkGuitarArticulations(), checkCoupledInstrumentBodies(), checkIRPreparationCancellation(), checkConvolutionExtension(), checkCompactStudioRoom(), checkSpatialShelf(), checkEQSketch(), checkMixedEQSketch(), checkReverbPeakHold(), checkReverbReconstructedPeaks(), checkMIDISeekState(), checkMIDIControllerHistory(), checkMIDINoteMultiplicity(), checkVintageBuildUp(), checkDriftingReverb(), checkClearReverb(), checkRetroReverb(), checkStudioDecayFilters(), checkPlateDecayFilters(), checkModalPlateMaterial(), checkRenderedReverbResponse(), checkEQDraftPreview(), checkEQDraftUpdates(), checkSynthGuitarSostenuto(), checkMacroCCMapping(), checkMIDIChannelMix(), checkMIDIOutputPolicy(), TrackProcessor::runMIDIOutputQueueRegression(), checkRelativeRPN(), checkOverlappingMIDIOrder(), checkPitchSourceConfidence(), checkPianoExpressivePerformance(), checkDrumPieceControls(), checkDrumArticulations(), checkGuitarPluckedLoop(), checkChorusSyncAndModes(), checkReverbWorkflow(), checkReverbScheduling(), checkPortableConvolution(), checkMatrixConvolution(), checkConvolutionColour(), checkConvolutionCrossTerms(), checkCompressorAverageMeter(), checkOutputMeters(), checkIRAudition(), checkIRDecayEstimate(), checkIRBandDecayEstimate(), checkOctaveIRAnalysis(), checkIRDirectGeometry(), checkEQAnalyzerResolution(), checkReverbLevelReadouts(), checkExternalDetectors(), checkPreviewOwnership(), checkModelMemoryAndHost() }) + const auto returnCalibration = checkReverbReturnCalibration(); + passed = passed && static_cast(returnCalibration["pass"]); + results.add(returnCalibration); + const auto returnIRCrossfade = checkReverbReturnIRCrossfade(); + passed = passed && static_cast(returnIRCrossfade["pass"]); + results.add(returnIRCrossfade); + const auto signalRepairs = checkFreeSignalRepairs(); + passed = passed && static_cast(signalRepairs["pass"]); + results.add(signalRepairs); + for (const auto& result : { checkGuitarPerformanceTelemetry(), checkOriginalColourSpectra(), checkOriginalColourStages(), checkDelayTimingTelemetry(), checkInstrumentPerformanceTelemetry(), checkSustainedHumanize(), checkGateOnsetQualification(), checkInstrumentReleaseTails(), checkConvolutionOutputs(), checkDrumMultiOutputs(), checkReconstructedLimiter(), checkInstrumentArticulation(), checkAdditionalReverbs(), checkCreativeReverbControls(), checkCreativeRoutingAndLateDecay(), checkReverbHoldPolicies(), checkReverbSpillover(), checkReverbOutputGain(), checkReverbControlBehavior(), checkReverbRetirement(), checkReverbFactoryPresets(), checkReverbRefinementState(), checkReverbSparseDiffusion(), checkSettledReverbInterpolation(), checkReverbCompatibilitySleep(), checkFreePluginAutomationRanges(), checkConvolutionMotion(), checkStudioPlate(), checkStudioSpaces(), checkVintageSpaces(), checkVintageBassDecay(), checkVintageConversion(), checkVintageTankRate(), checkLongReverbPredelay(), checkModalPlate(), checkSpatialLongDelay(), checkIRBrightness(), checkIRSourceBlend(), checkPreampTone(), checkPreampReferenceAndMeters(), checkFETWorkflow(), checkFETDetectorTilt(), checkFETMultiButtons(), checkVCANoiseMonitor(), checkNonlinearMotion(), checkOpticalWorkflow(), checkVCAWorkflow(), checkGraphicEQWorkflow(), checkManualPhaseAlignment(), checkSpectralPhaseAlignment(), checkExpandedEQ(), checkLinearPhaseEQ(), checkEQBandDetectors(), checkAdvancedEQCuts(), checkMinimumPhaseEQ(), checkAnalogTargetEQ(), checkEQDraftProcessingModes(), checkEQAdaptiveDynamics(), checkSpectralEQ(), checkLinearBandDynamics(), checkExtendedEQShapes(), checkLimiterOversampling(), checkIntegratedLoudness(), checkLimiterResponses(), checkLimiterStrategies(), checkLimiterGainWorkflow(), checkSaturationColour(), checkSpatialReverbs(), checkPlateColour(), checkEchoRooms(), checkAmbientSpaces(), checkDispersiveSpring(), checkSpringHold(), checkMagneticHold(), checkNonlinearHold(), checkPositionedHold(), checkEQMIDIPrograms(), checkEQPreparedPrograms(), checkContinuousAlignment(), checkPitchBoundedLagParity(), checkAutomaticAlignment(), checkLinkedStereoAlignment(), checkAllPassFitting(), checkAlignmentSections(), checkSparseAlignment(), checkProjectSpanAlignment(), checkAlignmentGroups(), checkEQMatching(), checkGateExpansion(), checkStandaloneDelayModes(), checkDelayDiffusion(), checkSynthFilterModulation(), checkSynthIndependentEnvelope(), checkSynthModulationMatrix(), checkSynthMPE(), checkSynthOscillatorShapes(), checkExpandedSynthMatrix(), checkSynthDestinations(), checkGuitarArticulations(), checkCoupledInstrumentBodies(), checkIRPreparationCancellation(), checkConvolutionExtension(), checkCompactStudioRoom(), checkSpatialShelf(), checkEQSketch(), checkMixedEQSketch(), checkReverbPeakHold(), checkReverbReconstructedPeaks(), checkMIDISeekState(), checkMIDIControllerHistory(), checkMIDINoteMultiplicity(), checkVintageBuildUp(), checkDriftingReverb(), checkClearReverb(), checkRetroReverb(), checkStudioDecayFilters(), checkPlateDecayFilters(), checkModalPlateMaterial(), checkRenderedReverbResponse(), checkEQDraftPreview(), checkEQDraftUpdates(), checkSynthGuitarSostenuto(), checkMacroCCMapping(), checkMIDIChannelMix(), checkMIDIOutputPolicy(), TrackProcessor::runMIDIOutputQueueRegression(), checkRelativeRPN(), checkOverlappingMIDIOrder(), checkPitchSourceConfidence(), checkPianoExpressivePerformance(), checkDrumPieceControls(), checkDrumArticulations(), checkGuitarPluckedLoop(), checkChorusSyncAndModes(), checkReverbWorkflow(), checkReverbScheduling(), checkPortableConvolution(), checkMatrixConvolution(), checkConvolutionColour(), checkConvolutionCrossTerms(), checkCompressorAverageMeter(), checkOutputMeters(), checkIRAudition(), checkIRDecayEstimate(), checkIRBandDecayEstimate(), checkOctaveIRAnalysis(), checkIRDirectGeometry(), checkEQAnalyzerResolution(), checkReverbLevelReadouts(), checkExternalDetectors(), checkPreviewOwnership(), checkModelMemoryAndHost() }) { passed = passed && static_cast(result["pass"]); results.add(result); diff --git a/Source/FreePluginReverbAudit.h b/Source/FreePluginReverbAudit.h new file mode 100644 index 0000000..722290c --- /dev/null +++ b/Source/FreePluginReverbAudit.h @@ -0,0 +1,103 @@ +#pragma once + +// Explicit research export. Measurements describe the current standalone +// processor; neither perceptual distinction nor acoustic fidelity is asserted. +inline juce::var auditStandaloneReverb(bool calibrated=false) +{ + constexpr double rate = 48000; + constexpr int blockSize = 128, seconds = 5, length = seconds * 48000; + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile(calibrated?"output/reverb-calibrated-audit/responses":"output/reverb-audit/responses"); + bool sane = directory.createDirectory().wasOk(); + const std::array originalNames { + "Room", "Hall", "Plate", "Chamber", "Spring", "Shimmer", "Nonlinear", "Convolution", + "Church", "Ambience", "Vintage hall", "Vintage random", "Contour room", "Open hall", "Diffuse loop", + "Magnetic heads", "Positioned room", "Rising space", "Bloom field", "Cloud field", "Vowel hall", + "Drifting hall", "Drifting chamber", "Drifting diffuse", "Clear plate", "Clear room", "Clear random" + }; + juce::Array cases; + for (int type = 0; type < OpenStudioReverb::standaloneTypeCount; ++type) + { + auto processor = std::make_unique(true); + processor->reverbReturnMode.store(calibrated?1.0f:0.0f); + processor->reverbControlMode.store(0); // Archived audit contract predates corrected control behavior. + const bool selected = setFreePluginParamForRegression(*processor, "reverbTypeAll", static_cast(type)) + && processor->algorithm.load() == static_cast(type); + const juce::String name = type < 27 ? originalNames[static_cast(type)] + : BuiltInRetroReverb::names[static_cast(type - 27)]; + const auto schema = describeFreePluginForRegression(*processor); + processor->wetLevel.store(1); processor->dryLevel.store(0); processor->sendMode.store(1); + processor->setRateAndBufferSizeDetails(rate, blockSize); + const auto prepareStart = juce::Time::getHighResolutionTicks(); + processor->prepareToPlay(rate, blockSize); + const double prepareMs = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - prepareStart) * 1000; + const int channels = juce::jmax(2, processor->getTotalNumOutputChannels()); + juce::AudioBuffer block(channels, blockSize), output(2, length); + juce::MidiBuffer midi; + // Settle selection/mix ramps before the diagnostic excitation. + for (int i = 0; i < 4800; i += blockSize) { block.clear(); processor->processBlock(block, midi); } + double energy = 0, peak = 0; + bool finite = true; + for (int position = 0; position < length; position += blockSize) + { + const int count = juce::jmin(blockSize, length - position); + block.setSize(channels, count, false, false, true); block.clear(); + if (position == 0) { block.setSample(0, 0, .25f); block.setSample(1, 0, .25f); } + processor->processBlock(block, midi); + for (int ch = 0; ch < 2; ++ch) + { + output.copyFrom(ch, position, block, ch, 0, count); + for (int i = 0; i < count; ++i) + { + const double value = block.getSample(ch, i); + finite = finite && std::isfinite(value); energy += value * value; peak = juce::jmax(peak, std::abs(value)); + } + } + } + const auto filename = juce::String(type).paddedLeft('0', 2) + "-" + name.replaceCharacter(' ', '-') + ".wav"; + const bool written = writeProbeWave(directory.getChildFile(filename), output, rate); + // CPU screen: warmed, active, editor closed. Timing includes the entire + // standalone wrapper, not only an isolated engine. No deadline assertion. + block.setSize(channels, blockSize, false, false, true); + std::vector times; times.reserve(512); + int overruns = 0; + for (int frame = -64; frame < 512; ++frame) + { + block.clear(); + for (int i = 0; i < blockSize; ++i) + { + const float value = .08f * std::sin(.0575959f * static_cast((frame + 64) * blockSize + i)); + block.setSample(0, i, value); block.setSample(1, i, value * .8f); + } + const auto start = juce::Time::getHighResolutionTicks(); processor->processBlock(block, midi); + const double micros = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - start) * 1e6; + if (frame >= 0) { times.push_back(micros); if (micros > blockSize / rate * 1e6) ++overruns; } + } + std::sort(times.begin(), times.end()); + auto* row = new juce::DynamicObject(); + row->setProperty("type", type); row->setProperty("name", name); row->setProperty("selectorReadback", selected); + row->setProperty("file", filename); row->setProperty("settingsBeforeWetOnlyOverride", schema); + row->setProperty("finite", finite); row->setProperty("nonzero", energy > 0); row->setProperty("fileWritten", written); + row->setProperty("impulseEnergy", energy); row->setProperty("peak", peak); + row->setProperty("nominalTailSeconds", processor->getTailLengthSeconds()); row->setProperty("prepareMs", prepareMs); + row->setProperty("medianUs", times[times.size() / 2]); row->setProperty("p95Us", times[times.size() * 95 / 100]); + row->setProperty("p99Us", times[times.size() * 99 / 100]); row->setProperty("maxUs", times.back()); + row->setProperty("deadlineOverruns", overruns); + cases.add(row); sane = selected && finite && energy > 0 && written && sane; + processor->releaseResources(); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Standalone OpenStudio Reverb all-mode research audit"); + result->setProperty("pass", sane); result->setProperty("cases", cases); result->setProperty("sampleRate", rate); + result->setProperty("blockSize", blockSize); result->setProperty("responseSeconds", seconds); + result->setProperty("returnLevel", calibrated?"Calibrated":"Original"); + result->setProperty("input", "Centered stereo single-sample impulse, 0.25 per channel; factory bank defaults, wet=1 dry=0, fresh processor per mode"); + result->setProperty("directory", directory.getFullPathName()); result->setProperty("cpu", juce::SystemStats::getCpuModel()); + #if JUCE_DEBUG + result->setProperty("build", "Debug: diagnostic_only"); + #else + result->setProperty("build", "Release optimized"); + #endif + result->setProperty("audioQuality", "not_asserted"); result->setProperty("acousticFidelity", "not_asserted"); + result->setProperty("responseMetrics", "diagnostic_only"); result->setProperty("timings", "diagnostic_only"); + result->setProperty("scope", "Pass asserts selector readback, finite nonzero response and WAV export only. Five-second responses can truncate long tails; synthetic impulse and CPU screen are not listening or device-dropout qualification."); + return result; +} diff --git a/Source/FreePluginReverbBalance.h b/Source/FreePluginReverbBalance.h new file mode 100644 index 0000000..5628ac7 --- /dev/null +++ b/Source/FreePluginReverbBalance.h @@ -0,0 +1,104 @@ +#pragma once + +// Opt-in native mixed exports. No file normalization or loudness-based gate: +// these validate wet readout arithmetic; listeners qualify perceived balance. +inline juce::var renderReverbBalancePack() +{ + constexpr int rate = 48000, frames = 128, length = rate * 8; + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-balance/native"); + bool passed = directory.createDirectory().wasOk(); + const auto sources = makeReverbListeningSources(); + const std::array materials {"percussion", "synthetic-vowel", "sustained-chord"}; + const std::array labels {"dry", "original100", "calibrated50", "calibrated100"}; + const std::array names { + "Room", "Hall", "Plate", "Chamber", "Spring", "Shimmer", "Nonlinear", "Convolution", + "Church", "Ambience", "Vintage hall", "Vintage random", "Contour room", "Open hall", "Diffuse loop", + "Magnetic heads", "Positioned room", "Rising space", "Bloom field", "Cloud field", "Vowel hall", + "Drifting hall", "Drifting chamber", "Drifting diffuse", "Clear plate", "Clear room", "Clear random" + }; + const auto render = [&] (int type, size_t material, int variant) + { + auto processor = std::make_unique(true); + processor->selectAlgorithm(type); + processor->dryLevel.store(.7f); + processor->wetLevel.store(variant == 0 ? 0.0f : variant == 2 ? .5f : 1.0f); + processor->sendMode.store(0); processor->outputTrimDb.store(0); + processor->reverbCharacterMode.store(0); processor->reverbReturnMode.store(variant >= 2 ? 1.0f : 0.0f); + processor->prepareToPlay(rate, frames); + juce::AudioBuffer block(juce::jmax(2, processor->getTotalNumOutputChannels()), frames), output(2, length); + juce::MidiBuffer midi; + for (int start = 0; start < rate / 10; start += frames) { block.clear(); processor->processBlock(block, midi); } + for (int start = 0; start < length; start += frames) + { + block.clear(); + for (int channel = 0; channel < 2; ++channel) block.copyFrom(channel, 0, sources[material], channel, start, frames); + processor->processBlock(block, midi); + for (int channel = 0; channel < 2; ++channel) output.copyFrom(channel, start, block, channel, 0, frames); + } + processor->releaseResources(); + return output; + }; + juce::Array rows; + double maximumHalfError = 0, maximumGainError = 0; + for (int type = 0; type < OpenStudioReverb::standaloneTypeCount; ++type) + for (size_t material = 0; material < materials.size(); ++material) + { + std::array, 4> outputs; + for (int variant = 0; variant < 4; ++variant) outputs[static_cast(variant)] = render(type, material, variant); + auto* files = new juce::DynamicObject(); auto* peaks = new juce::DynamicObject(); + bool finite = true, written = true; + double dryEnergy = 0, wet50Energy = 0, wet100Energy = 0, originalEnergy = 0; + double halfError = 0, gainError = 0, tailEnergy = 0; + const float gain = BuiltInReverbReturnLevels::linear[static_cast(type)]; + for (int variant = 0; variant < 4; ++variant) + { + const auto filename = juce::String(type).paddedLeft('0', 2) + "-" + materials[material] + "-" + labels[static_cast(variant)] + ".wav"; + files->setProperty(labels[static_cast(variant)], filename); + written = writeProbeWave(directory.getChildFile(filename), outputs[static_cast(variant)], rate) && written; + double peak = 0; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < length; ++sample) + { + const double value = outputs[static_cast(variant)].getSample(channel, sample); + finite = std::isfinite(value) && finite; peak = juce::jmax(peak, std::abs(value)); + } + peaks->setProperty(labels[static_cast(variant)], peak); + } + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < length; ++sample) + { + const double dry = outputs[0].getSample(channel, sample); + const double original = outputs[1].getSample(channel, sample) - dry; + const double half = outputs[2].getSample(channel, sample) - dry; + const double full = outputs[3].getSample(channel, sample) - dry; + halfError = juce::jmax(halfError, std::abs(half - .5 * full)); + gainError = juce::jmax(gainError, std::abs(full - gain * original)); + if (sample < rate * 3) { dryEnergy += dry * dry; originalEnergy += original * original; wet50Energy += half * half; wet100Energy += full * full; } + else tailEnergy += full * full; + } + maximumHalfError = juce::jmax(maximumHalfError, halfError); maximumGainError = juce::jmax(maximumGainError, gainError); + const auto rms = [rate] (double energy) { return std::sqrt(energy / (2 * rate * 3)); }; + const auto ratio = [] (double wet, double dry) { return 10 * std::log10(juce::jmax(1e-30, wet) / juce::jmax(1e-30, dry)); }; + auto* row = new juce::DynamicObject(); row->setProperty("type", type); + row->setProperty("name", type < 27 ? juce::String(names[static_cast(type)]) : juce::String(BuiltInRetroReverb::names[static_cast(type - 27)])); + row->setProperty("material", materials[material]); row->setProperty("files", juce::var(files)); row->setProperty("peaks", juce::var(peaks)); + row->setProperty("dryRms", rms(dryEnergy)); row->setProperty("originalWet100Rms", rms(originalEnergy)); + row->setProperty("wet50Rms", rms(wet50Energy)); row->setProperty("wet100Rms", rms(wet100Energy)); + row->setProperty("original100VsDryDb", ratio(originalEnergy, dryEnergy)); + row->setProperty("wet50VsDryDb", ratio(wet50Energy, dryEnergy)); row->setProperty("wet100VsDryDb", ratio(wet100Energy, dryEnergy)); + row->setProperty("calibrationDb", BuiltInReverbReturnLevels::decibels[static_cast(type)]); + row->setProperty("wetHalfMaxError", halfError); row->setProperty("calibrationMaxError", gainError); + row->setProperty("wetTailEnergy", tailEnergy); row->setProperty("finite", finite); row->setProperty("filesWritten", written); + const bool rowPass = finite && written && dryEnergy > 0 && wet100Energy > 0 && halfError < 2e-5 && gainError < 2e-5; + row->setProperty("pass", rowPass); passed = rowPass && passed; rows.add(juce::var(row)); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Standalone Reverb native Wet/Dry balance"); + result->setProperty("pass", passed); result->setProperty("cases", rows); result->setProperty("directory", directory.getFullPathName()); + result->setProperty("sampleRate", rate); result->setProperty("seconds", 8); result->setProperty("excitationSeconds", 3); + result->setProperty("dry", .7); result->setProperty("outputDb", 0); result->setProperty("processing", "Original"); + result->setProperty("maximumHalfError", maximumHalfError); result->setProperty("maximumGainError", maximumGainError); + result->setProperty("levels", "Native mixed output; residual wet = mixed minus separately rendered native dry; no normalization"); + result->setProperty("rmsBalance", "diagnostic_only"); result->setProperty("audibility", "not_asserted"); + result->setProperty("dominance", "not_asserted"); result->setProperty("audioQuality", "not_asserted"); + return juce::var(result); +} diff --git a/Source/FreePluginReverbCompatibilityRegression.h b/Source/FreePluginReverbCompatibilityRegression.h new file mode 100644 index 0000000..aacdf4f --- /dev/null +++ b/Source/FreePluginReverbCompatibilityRegression.h @@ -0,0 +1,257 @@ +#pragma once + +#if JUCE_WINDOWS && JUCE_DEBUG +#include +namespace ReverbCompatibilityHeapProbe +{ +inline thread_local bool active = false; +inline thread_local int allocations = 0, frees = 0; +inline _CRT_ALLOC_HOOK previous = nullptr; +inline int hook(int kind, void* data, size_t size, int blockType, long request, const unsigned char* file, int line) +{ + if (active) + { + if (kind == _HOOK_ALLOC || kind == _HOOK_REALLOC) ++allocations; + if (kind == _HOOK_FREE) ++frees; + } + return previous ? previous(kind, data, size, blockType, request, file, line) : 1; +} +} +#endif + +// Include outside FreePluginRegression.cpp's anonymous namespace so this +// fixture's narrowly-scoped friend matches the production declaration. +struct OpenStudioReverbCompatibilityRegressionAccess +{ + static void forceProcessing(OpenStudioReverb& reverb, bool force) { reverb.compatibilitySleepEnabled = !force; } + static std::uint64_t calls(const OpenStudioReverb& reverb) { return reverb.compatibilityProcessingCalls; } + static bool sleeping(const OpenStudioReverb& reverb) { return reverb.compatibilitySleeping; } + static void unguardedV2(OpenStudioReverb& reverb) { reverb.v2DiffusionHistoryGuardsEnabled = false; } + static void zeroV2Pool(OpenStudioReverb& reverb) { std::fill(reverb.v2DiffusionPool.begin(), reverb.v2DiffusionPool.end(), 0.0f); } + static const BuiltInInfiniteHoldBudget& nonlinearBudget(const OpenStudioReverb& reverb){return reverb.additionalSpaces.nonlinearInfiniteBudget();} + static const BuiltInInfiniteHoldBudget& nonlinearLateBudget(const OpenStudioReverb& reverb){return reverb.additionalSpaces.nonlinearLateInfiniteBudget();} + static const BuiltInInfiniteHoldBudget& springBudget(const OpenStudioReverb& reverb){return reverb.springSpace.infiniteBudget();} + static const BuiltInInfiniteHoldBudget& risingBudget(const OpenStudioReverb& reverb){return reverb.ambientSpace.risingInfiniteBudget();} + static bool shimmerSettled(const OpenStudioReverb& reverb){return reverb.additionalSpaces.shimmerSchedulingSettled();} + static bool shimmerSpread(const OpenStudioReverb& reverb){return reverb.additionalSpaces.shimmerSchedulingSpread();} + static std::array smoothers(const OpenStudioReverb& reverb) + { + return {reverb.smoothedWetLevel.getCurrentValue(), reverb.smoothedDryLevel.getCurrentValue(), + reverb.smoothedEarlyLevel.getCurrentValue(), reverb.smoothedInputSend.getCurrentValue(), + reverb.smoothedShimmerAmount.getCurrentValue(), reverb.smoothedV2Width.getCurrentValue(), + reverb.smoothedV2Ducking.getCurrentValue(), reverb.smoothedV2Movement.getCurrentValue(), + reverb.smoothedV2EarlyLate.getCurrentValue(), reverb.smoothedV2Diffusion.getCurrentValue(), + reverb.smoothedV2ShimmerRegen.getCurrentValue(), reverb.smoothedV5AlternativeVoiceBlend.getCurrentValue(), + reverb.smoothedV3PadMode.getCurrentValue(), reverb.smoothedV3LoopDampingCoefficient.getCurrentValue()}; + } +}; + +inline juce::var checkReverbCompatibilitySleep() +{ + using Access = OpenStudioReverbCompatibilityRegressionAccess; + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Standalone compatibility dispatcher sleep"); + result->setProperty("audioQuality", "not_asserted"); + result->setProperty("timing", "diagnostic_only"); + juce::Array cases; + bool overall = true; + double maximumOutputError = 0, maximumSmootherError = 0; + int allocations = 0, frees = 0; + bool heapMeasured = false; + const auto processChecked = [&] (OpenStudioReverb& processor, juce::AudioBuffer& audio, juce::MidiBuffer& midi) { +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations = ReverbCompatibilityHeapProbe::frees = 0; + ReverbCompatibilityHeapProbe::previous = _CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook); + ReverbCompatibilityHeapProbe::active = true; +#endif + processor.processBlock(audio, midi); +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active = false; + _CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous); + allocations += ReverbCompatibilityHeapProbe::allocations; + frees += ReverbCompatibilityHeapProbe::frees; + heapMeasured = true; +#endif + }; + const auto add = [&] (juce::DynamicObject* item, bool pass) { + item->setProperty("pass", pass); cases.add(juce::var(item)); overall = overall && pass; + }; + const auto configure = [] (OpenStudioReverb& reverb, int channels, int type, float engine, double rate) { + auto layout = reverb.getBusesLayout(); + layout.inputBuses.set(0, channels == 1 ? juce::AudioChannelSet::mono() : juce::AudioChannelSet::stereo()); + layout.outputBuses.set(0, channels == 1 ? juce::AudioChannelSet::mono() : juce::AudioChannelSet::stereo()); + const bool layoutPass = reverb.setBusesLayout(layout); + reverb.selectAlgorithm(type); reverb.engineVersion.store(engine); + reverb.wetLevel.store(.6f); reverb.dryLevel.store(.25f); reverb.shimmerAmount.store(.17f); + reverb.prepareToPlay(rate, 512); + return layoutPass; + }; + const auto signal = [] (int sample, int channel, double rate) { + return .035f * std::sin(static_cast(sample) * static_cast(1079.0 / rate)) + * (channel == 0 ? 1.0f : -.73f) + (sample == 0 ? .125f : 0.0f); + }; + const auto renderPair = [&] (double rate, int channels, int type, float engine, bool embedded, bool oldDiffuser) { + const int previousAllocations = allocations, previousFrees = frees; + auto optimized = std::make_unique(!embedded); + auto baseline = std::make_unique(!embedded); + bool pass = configure(*optimized, channels, type, engine, rate) + && configure(*baseline, channels, type, engine, rate); + Access::forceProcessing(*baseline, true); + if (oldDiffuser) Access::unguardedV2(*baseline); + const int length = juce::roundToInt(rate * .30), event = juce::roundToInt(rate * .18); + const int settled = juce::roundToInt(rate * .14); + const int outputs = optimized->getTotalNumOutputChannels(); + juce::AudioBuffer actual(outputs, 512), expected(outputs, 512); + juce::MidiBuffer midi; + double outputError = 0, smootherError = 0; + std::uint64_t settledCalls = 0; + for (int start = 0, block = 0; start < length; ++block) + { + if (start == event) + { + for (auto* processor : {optimized.get(), baseline.get()}) + { + processor->wetLevel.store(.39f); processor->dryLevel.store(.67f); + processor->width.store(.31f); processor->movement.store(.71f); + processor->earlyLevel.store(.19f); processor->diffusion.store(.81f); + processor->ducking.store(.61f); processor->earlyLate.store(.76f); + processor->shimmerAmount.store(.31f); processor->shimmerRegen.store(.29f); + } + if (oldDiffuser) + { + optimized->resetTailState(); baseline->resetTailState(); + // Historical baseline explicitly swept this V2 pool. + // This fixture performs that sweep outside processBlock. + Access::zeroV2Pool(*baseline); + } + } + const std::array partitions {17,127,512,63}; + int count = juce::jmin(partitions[static_cast(block % 4)], length - start); + if (start < event) count = juce::jmin(count, event - start); + actual.setSize(outputs, count, false, false, true); actual.clear(); + for (int ch = 0; ch < channels; ++ch) + for (int i = 0; i < count; ++i) actual.setSample(ch, i, signal(start + i, ch, rate)); + expected.makeCopyOf(actual, true); + const auto before = Access::calls(*optimized); + processChecked(*optimized, actual, midi); baseline->processBlock(expected, midi); + if (start >= settled) settledCalls += Access::calls(*optimized) - before; + for (int ch = 0; ch < outputs; ++ch) + for (int i = 0; i < count; ++i) + { + pass = std::isfinite(actual.getSample(ch, i)) && pass; + outputError = juce::jmax(outputError, std::abs(static_cast(actual.getSample(ch, i)) - expected.getSample(ch, i))); + } + const auto a = Access::smoothers(*optimized), b = Access::smoothers(*baseline); + for (size_t i = 0; i < a.size(); ++i) smootherError = juce::jmax(smootherError, std::abs(static_cast(a[i]) - b[i])); + start += count; + } + pass = pass && outputError == 0 && smootherError == 0; + pass = pass && (embedded ? settledCalls > 0 && !Access::sleeping(*optimized) + : settledCalls == 0 && Access::sleeping(*optimized)); + maximumOutputError = juce::jmax(maximumOutputError, outputError); + maximumSmootherError = juce::jmax(maximumSmootherError, smootherError); + auto* item = new juce::DynamicObject(); item->setProperty("kind", oldDiffuser ? "guarded_vs_swept_v2" : embedded ? "embedded_nam_unchanged" : "forced_processing_parity"); + item->setProperty("sampleRate", rate); item->setProperty("channels", channels); item->setProperty("type", type); + item->setProperty("engine", engine); item->setProperty("maxOutputError", outputError); + item->setProperty("maxSmootherError", smootherError); item->setProperty("settledCompatibilityCalls", static_cast(settledCalls)); + item->setProperty("allocations", allocations - previousAllocations); item->setProperty("frees", frees - previousFrees); + add(item, pass); + }; + for (const double rate : {44100.0,48000.0,96000.0,192000.0}) + for (const int channels : {1,2}) + { + for (const float engine : {1.0f,2.0f,3.0f,4.0f,5.0f}) + { + renderPair(rate, channels, 0, engine, false, false); + renderPair(rate, channels, 0, engine, true, engine == 2.0f); + } + } + for (int type = 1; type < OpenStudioReverb::standaloneTypeCount; ++type) + renderPair(48000, 2, type, 1, false, false); + + // Exercise sleeping -> audible compatibility transitions with silent input. + // Outgoing dedicated tanks are allowed to finish their 50 ms selection ramp; + // their presence must not be mistaken for resurrected compatibility history. + for (const double rate : {44100.0,48000.0,96000.0,192000.0}) + for (const int channels : {1,2}) + for (const float engine : {1.0f,2.0f,3.0f,4.0f,5.0f}) + { + const int previousAllocations = allocations, previousFrees = frees; + auto processor = std::make_unique(true); + bool pass = configure(*processor, channels, 6, engine, rate); + processor->dryLevel.store(0); processor->wetLevel.store(1); + juce::AudioBuffer block(processor->getTotalNumOutputChannels(), 512); juce::MidiBuffer midi; + const int wake = juce::roundToInt(rate * .20), end = juce::roundToInt(rate * .39); + std::uint64_t wakeCalls = 0; double silencePeak = 0; + for (int start = 0; start < end;) + { + if (start == wake) + { + pass = pass && Access::sleeping(*processor); + processor->studioEngines[0].store(0); processor->selectAlgorithm(0); + processor->dryLevel.store(0); processor->wetLevel.store(1); + wakeCalls = Access::calls(*processor); + } + int count = juce::jmin(127, end - start); if (start < wake) count = juce::jmin(count, wake - start); + block.setSize(block.getNumChannels(), count, false, false, true); block.clear(); + if (start < juce::roundToInt(rate * .035)) + for (int ch = 0; ch < channels; ++ch) + for (int i = 0; i < count; ++i) block.setSample(ch, i, signal(start + i, ch, rate)); + processChecked(*processor, block, midi); + if (start >= wake + juce::roundToInt(rate * .065)) silencePeak = juce::jmax(silencePeak, static_cast(block.getMagnitude(0, count))); + start += count; + } + pass = pass && Access::calls(*processor) > wakeCalls && !Access::sleeping(*processor) && silencePeak == 0; + auto* item = new juce::DynamicObject(); item->setProperty("kind", "silent_compatibility_wake"); + item->setProperty("sampleRate", rate); item->setProperty("channels", channels); item->setProperty("engine", engine); + item->setProperty("postFadeSilencePeak", silencePeak); + item->setProperty("allocations", allocations - previousAllocations); item->setProperty("frees", frees - previousFrees); add(item, pass); + } + + // IR response, layout and selector edits each abandon the old coverage proof. + auto reverb = std::make_unique(true); + bool publicationPass = configure(*reverb, 2, 7, 1, 48000); + juce::AudioBuffer block(reverb->getTotalNumOutputChannels(), 127); juce::MidiBuffer midi; + const auto settle = [&] { for (int i = 0; i < 80; ++i) { block.clear(); processChecked(*reverb, block, midi); } return Access::sleeping(*reverb); }; + const auto wakes = [&] { const auto before = Access::calls(*reverb); block.clear(); processChecked(*reverb, block, midi); return Access::calls(*reverb) > before && !Access::sleeping(*reverb); }; + juce::Array publicationEvents; + const auto publicationEvent = [&] (const char* name, bool pass) { + auto* event = new juce::DynamicObject(); event->setProperty("event", name); event->setProperty("pass", pass); + event->setProperty("sleeping", Access::sleeping(*reverb)); event->setProperty("calls", static_cast(Access::calls(*reverb))); + publicationEvents.add(event); publicationPass = publicationPass && pass; + }; + publicationEvent("initial_settle", settle()); + // Split auxiliary layout is valid only for a four-path IR. Publish a + // deterministic prepared matrix first; a stereo default must reject that + // layout, so using it here would test an invalid request rather than wake. + juce::MemoryBlock matrixBytes; + { + juce::MemoryOutputStream stream(matrixBytes, false); + stream.writeInt(0x4f534952); stream.writeDouble(48000); stream.writeInt(4); stream.writeInt(2048); + for (int channel = 0; channel < 4; ++channel) + for (int sample = 0; sample < 2048; ++sample) + stream.writeFloat(sample == 60 + channel * 47 ? .3f : 0.0f); + } + publicationEvent("matrix_replacement", reverb->convolutionSpace.restore(juce::var(matrixBytes), "Compatibility matrix fixture", 0)); + publicationEvent("replacement_wake", wakes()); publicationEvent("replacement_settle", settle()); + const auto previousRevision = reverb->convolutionSpace.publicationRevision(); + auto* layout = new juce::DynamicObject(); layout->setProperty("outputLayout", 1); + publicationEvent("layout_edit", reverb->convolutionSpace.edit(juce::var(layout))); + publicationEvent("layout_revision", reverb->convolutionSpace.publicationRevision() > previousRevision); + publicationEvent("layout_wake", wakes()); publicationEvent("layout_settle", settle()); + auto* response = new juce::DynamicObject(); response->setProperty("reverse", true); + publicationEvent("response_edit", reverb->convolutionSpace.edit(juce::var(response))); + publicationEvent("response_wake", wakes()); publicationEvent("response_settle", settle()); + reverb->selectAlgorithm(2); publicationEvent("selector_wake", wakes()); publicationEvent("selector_settle", settle()); + reverb->plateCharacter.store(2); publicationEvent("plate_character_wake", wakes()); publicationEvent("plate_character_settle", settle()); + reverb->engineVersion.store(2); publicationEvent("engine_wake", wakes()); publicationEvent("engine_settle", settle()); + reverb->tailSpillover.store(1); publicationEvent("spillover_wake", wakes()); publicationEvent("spillover_settle", settle()); + reverb->plateEngine.store(0); publicationEvent("legacy_wake", wakes()); publicationEvent("legacy_remains_awake", !settle()); + auto* publication = new juce::DynamicObject(); publication->setProperty("kind", "routing_and_ir_publication_wake"); + publication->setProperty("events", publicationEvents); add(publication, publicationPass); + result->setProperty("cases", cases); result->setProperty("caseCount", cases.size()); + result->setProperty("maxOutputError", maximumOutputError); result->setProperty("maxSmootherError", maximumSmootherError); + result->setProperty("audioThreadHeapMeasured", heapMeasured); + result->setProperty("audioThreadAllocations", allocations); result->setProperty("audioThreadFrees", frees); + result->setProperty("pass", overall && allocations == 0 && frees == 0); return juce::var(result); +} diff --git a/Source/FreePluginReverbControlRegression.h b/Source/FreePluginReverbControlRegression.h new file mode 100644 index 0000000..40d8766 --- /dev/null +++ b/Source/FreePluginReverbControlRegression.h @@ -0,0 +1,148 @@ +#pragma once + +// Complete native mix-path references, including auxiliary convolution output. +// This is a control/compatibility gate, not an audible-quality assessment. +inline juce::var checkReverbControlBehavior() +{ + using namespace ReverbReturnRegression; + auto* result = new juce::DynamicObject(); + juce::Array rows; + bool overall = true; + const auto add = [&](const juce::String& name, bool pass, double maximum = 0) { + auto* row = new juce::DynamicObject(); row->setProperty("kind", name); + row->setProperty("pass", pass); row->setProperty("maximumError", maximum); + rows.add(row); overall = overall && pass; + }; + auto fresh = std::make_unique(true); + auto restored = std::make_unique(true); + auto embedded = std::make_unique(false); + bool state = fresh->reverbControlMode.load() == 1 && embedded->reverbControlMode.load() == 0; + state = setFreePluginParamForRegression(*fresh, "reverbControlMode", 0) && state; + juce::MemoryBlock saved, repeated; fresh->getStateInformation(saved); + restored->setStateInformation(saved.getData(), static_cast(saved.getSize())); + restored->getStateInformation(repeated); + state = saved == repeated && restored->reverbControlMode.load() == 0 && state; + state = setFreePluginNormalizedForRegression(*restored, "reverbControlMode", 1) && state; + restored->selectAlgorithm(40); restored->selectSendMode(true); + state = restored->reverbControlMode.load() == 1 && state; + auto old = juce::ValueTree::readFromData(saved.getData(), saved.getSize()); + old.removeProperty("reverbControlMode", nullptr); const auto missing = bytes(old); + restored->setStateInformation(missing.getData(), static_cast(missing.getSize())); + state = restored->reverbControlMode.load() == 0 && state; + old.setProperty("reverbControlMode", std::numeric_limits::quiet_NaN(), nullptr); + const auto invalid = bytes(old); restored->setStateInformation(invalid.getData(), static_cast(invalid.getSize())); + state = restored->reverbControlMode.load() == 0 && state; + state = !setFreePluginParamForRegression(*embedded, "reverbControlMode", 1) + && !setFreePluginNormalizedForRegression(*embedded, "reverbControlMode", 1) && state; + const auto params = describeFreePluginForRegression(*fresh)["parameters"]; + state = params.size() == 1090 && params[1089]["id"].toString() == "reverbControlMode" && state; + add("state_migration_append_only_and_embedded", state); + + const auto matrixFile=juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-correction/matrix-wet-ramp.wav"); + juce::AudioBuffer matrixIR(4,128); matrixIR.clear(); + for (int ch=0;ch<4;++ch) matrixIR.setSample(ch,3+ch*19,.2f+static_cast(ch)*.04f); + add("matrix_ir_fixture",writeProbeWave(matrixFile,matrixIR,48000)); + int allocations = 0, frees = 0; bool heapMeasured = false, finite = true; + const auto render = [&](int type, double rate, int variant, bool irregular, int mode = 1) { + auto processor = std::make_unique(true); + processor->selectAlgorithm(type); processor->reverbControlMode.store(static_cast(mode)); + processor->wetLevel.store(variant == 1 ? 0.0f : 1.0f); + processor->dryLevel.store(variant == 1 ? 1.0f : 0.0f); + if (type == 7) { + auto* settings=new juce::DynamicObject(); settings->setProperty("outputLayout",1); settings->setProperty("normalise",false); + const bool loaded=processor->convolutionSpace.loadFile(matrixFile); + const bool shaped=processor->convolutionSpace.edit(juce::var(settings)); + overall=loaded&&shaped&&overall; + } + if (variant == 3) { + processor->roomSize.store(0); + processor->spatialControls[static_cast(type-12)][5].store(1); + processor->spatialControls[static_cast(type-12)][6].store(type==14?20.0f:2.0f); + } + processor->prepareToPlay(rate, 128); + const int length = juce::roundToInt(rate * .30), first = juce::roundToInt(rate * .18), reverse = juce::roundToInt(rate * .19); + const int outputs = processor->getTotalNumOutputChannels(); + juce::AudioBuffer output(outputs, length), block(outputs, 257); juce::MidiBuffer midi; + constexpr std::array sizes{1, 37, 113, 257}; + for (int start = 0, part = 0; start < length; ++part) + { + if (variant == 2 && start == first) { processor->wetLevel.store(0); processor->dryLevel.store(1); } + if (variant == 2 && start == reverse) { processor->wetLevel.store(.8f); processor->dryLevel.store(.2f); } + if (variant == 3 && (start == first || start == reverse)) { + processor->roomSize.store(start==first?1.0f:0.0f); + processor->spatialControls[static_cast(type-12)][5].store(start==first?0.0f:1.0f); + } + int count = juce::jmin(irregular ? sizes[static_cast(part % 4)] : 128, length-start); + if (start < first) count = juce::jmin(count, first-start); + if (start < reverse) count = juce::jmin(count, reverse-start); + block.setSize(outputs, count, false, false, true); block.clear(); + for (int i=0; i(std::sin(t*1741) + .4*std::sin(t*7193)); + block.setSample(0,i,input); block.setSample(1,i,-input*.71f); + } +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations = ReverbCompatibilityHeapProbe::frees = 0; + ReverbCompatibilityHeapProbe::previous = _CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook); + ReverbCompatibilityHeapProbe::active = true; +#endif + processor->processBlock(block,midi); +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active = false; _CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous); + allocations += ReverbCompatibilityHeapProbe::allocations; frees += ReverbCompatibilityHeapProbe::frees; heapMeasured = true; +#endif + for(int ch=0;ch1e-6f&&wet.getMagnitude(3,0,wet.getNumSamples())>1e-6f); + const auto moved = render(type,rate,2,false), split = render(type,rate,2,true); + double maximum = 0; + for(int i=8640;isetProperty("plugin","Standalone corrected controls"); result->setProperty("pass",overall); + result->setProperty("cases",rows); result->setProperty("callbackAllocations",allocations); result->setProperty("callbackFrees",frees); + result->setProperty("heapMeasured",heapMeasured); result->setProperty("audioQuality","not_asserted"); + return result; +} diff --git a/Source/FreePluginReverbCorrectionRegression.h b/Source/FreePluginReverbCorrectionRegression.h new file mode 100644 index 0000000..92400cf --- /dev/null +++ b/Source/FreePluginReverbCorrectionRegression.h @@ -0,0 +1,79 @@ +#pragma once + +// Long native captures are diagnostics. Only deterministic invariants are gates. +inline juce::var checkReverbCorrectionResponses() +{ + const auto directory=juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-correction/long-responses"); + bool passed=directory.createDirectory().wasOk(); + juce::Array rows; + const auto render=[&](int type,double rate,int mode,int route,float amount,float decay,const juce::String& kind,bool transitions=false) + { + auto processor=std::make_unique(true); + juce::MemoryBlock state; + const auto& profile=OpenStudioReverbFactoryPresets::profiles[static_cast(type)]; + const bool built=OpenStudioReverbFactoryPresets::buildState(profile.name,state); + processor->setStateInformation(state.getData(),static_cast(state.getSize())); + processor->reverbControlMode.store(static_cast(mode)); + processor->outputTrimDb.store(0);processor->wetLevel.store(1);processor->dryLevel.store(0); + processor->decayTime.store(decay);processor->damping.store(kind.startsWith("profile-")?.59f:.5f); + processor->creativeControls[0].store(static_cast(route));processor->shimmerAmount.store(amount); + processor->tailSpillover.store(transitions?1.0f:0.0f); + if(kind=="original-readout")processor->reverbCharacterMode.store(0); + if(kind=="refined-readout")processor->reverbCharacterMode.store(1); + processor->prepareToPlay(rate,128); + const int length=static_cast(rate*20),channels=juce::jmax(2,processor->getTotalNumOutputChannels()); + juce::AudioBuffer output(2,length),block(channels,128);juce::MidiBuffer midi; + for(int start=0;start(rate*.1);start+=128){block.clear();processor->processBlock(block,midi);} + bool finite=true;double energy=0,maximumCallback=0;int allocations=0,frees=0; + const auto started=juce::Time::getMillisecondCounterHiRes(); + for(int start=0;startdamping.store(seconds>=4&&seconds<8?.9f:.2f); + const float hold=seconds>=2&&seconds<4?1.0f:0.0f; + processor->shimmerHold.store(hold);processor->nonlinearHold.store(hold);processor->freezeMode.store(hold); + if(type==5)processor->creativeControls[0].store(seconds<6?0.0f:seconds<10?2.0f:1.0f); + if(seconds>=12&&processor->algorithm.load()==static_cast(type))processor->selectAlgorithm(9); + } +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations=ReverbCompatibilityHeapProbe::frees=0; + ReverbCompatibilityHeapProbe::previous=_CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook); + ReverbCompatibilityHeapProbe::active=true; +#endif + const auto callback=juce::Time::getMillisecondCounterHiRes();processor->processBlock(block,midi); + maximumCallback=juce::jmax(maximumCallback,juce::Time::getMillisecondCounterHiRes()-callback); +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active=false;_CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous); + allocations+=ReverbCompatibilityHeapProbe::allocations;frees+=ReverbCompatibilityHeapProbe::frees; +#endif + for(int ch=0;ch<2;++ch){output.copyFrom(ch,start,block,ch,0,count);for(int i=0;i(rate))+"-m"+juce::String(mode)+"-route"+juce::String(route)+"-a"+juce::String(amount,2)+"-d"+juce::String(decay,1)+".wav"; + const bool rowPass=built&&finite&&energy>0&&allocations==0&&frees==0&&writeProbeWave(directory.getChildFile(filename),output,rate); + auto* row=new juce::DynamicObject();row->setProperty("file",filename);row->setProperty("kind",kind);row->setProperty("type",type);row->setProperty("rate",rate);row->setProperty("mode",mode);row->setProperty("route",route);row->setProperty("amount",amount);row->setProperty("decay",decay); + row->setProperty("peak",output.getMagnitude(0,length));row->setProperty("energy",energy);row->setProperty("wallMsDiagnostic",wall);row->setProperty("maxCallbackMsDiagnostic",maximumCallback);row->setProperty("allocations",allocations);row->setProperty("frees",frees);row->setProperty("pass",rowPass); + rows.add(row);passed=rowPass&&passed; + directory.getParentDirectory().getChildFile("long-progress.json").replaceWithText(juce::String(rows.size())); + }; + const bool transitionsOnly=juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_REVERB_QA_TRANSITIONS_ONLY","0")=="1"; + if(!transitionsOnly)for(int type:{0,1,2,5,6,24,25,26})for(double rate:{44100.0,48000.0,96000.0,192000.0})for(int mode:{0,1}) + render(type,rate,mode,1,.56f,6,"rate"); + if(!transitionsOnly)for(int route:{0,1,2})for(float amount:{0.0f,.56f,1.0f})for(float decay:{2.0f,11.5f}) + render(5,48000,1,route,amount,decay,"shimmer"); + for(int type:{0,2,5,6,24})render(type,48000,1,0,.56f,6,"transition",true); + if(!transitionsOnly)for(int type:{2,19})for(const auto* kind:{"original-readout","refined-readout"})render(type,48000,1,1,.56f,6,kind); + if(!transitionsOnly)for(double rate:{44100.0,48000.0}){render(5,rate,0,0,.56f,11.5f,"profile-before");render(5,rate,1,1,.56f,11.5f,"profile-after");} + auto* result=new juce::DynamicObject();result->setProperty("plugin","Standalone Reverb long correction diagnostics");result->setProperty("cases",rows);result->setProperty("caseCount",rows.size());result->setProperty("pass",passed); + +#if JUCE_WINDOWS && JUCE_DEBUG + result->setProperty("heapMeasured",true); +#else + result->setProperty("heapMeasured",false); +#endif + result->setProperty("audioQuality","not_asserted");result->setProperty("decaySpectrumTiming","diagnostic_only");return juce::var(result); +} diff --git a/Source/FreePluginReverbExtendedRegression.h b/Source/FreePluginReverbExtendedRegression.h new file mode 100644 index 0000000..577f4d3 --- /dev/null +++ b/Source/FreePluginReverbExtendedRegression.h @@ -0,0 +1,442 @@ +#pragma once +#include + +// Opt-in, deliberately longer than the ordinary regression suite. This checks +// actual wrapper safety and callback partitioning, not musical quality, an +// acoustic RT60 contract, indefinite Hold, or hardware/device performance. +// Include after FreePluginReverbCompatibilityRegression.h for its CRT probe. +namespace ReverbExtendedRegression +{ +struct Options +{ + int type = 0, channels = 2, processing = 0; + double rate = 48000; + bool hold = false, boundedProbe = false; + bool spreadShimmer = false; + float vowelBody = 0; + int nonlinearShape = 0; + float nonlinearMotion = 0, nonlinearMotionRate = .7f; + int returnMode = 1; + bool heldAcceptsInput = true; + float outputDb = 0; + bool liveReturnSwitch=false; +}; + +struct Measurement +{ + bool prepared = true, finite = true, stateParity = true; + double peak = 0, partitionError = 0, inputEnergy = 0, releaseStartEnergy = 0; + double finalEnergy = 0, silentHoldEnergy = 0, outputEnergy = 0; + juce::int64 samplesAtSafetyBound = 0, callbacks = 0; + std::array phaseSafetyBoundSamples {}; + double firstSafetyBoundSeconds = -1; + juce::int64 finalSamples = 0, releaseSamples = 0, silentHoldSamples = 0; + int allocations = 0, frees = 0; + bool heapMeasured = false; + double seconds = 0, declaredReleaseTail = 0; + double nonlinearStoredPeak=0,nonlinearLateStoredPeak=0; + std::uint64_t nonlinearBudgetInterventions=0,nonlinearLateBudgetInterventions=0; + double springStoredPeak=0,risingStoredPeak=0; + std::uint64_t springBudgetInterventions=0,risingBudgetInterventions=0; +}; + +inline void setHold(OpenStudioReverb& processor, int type, bool held, bool acceptsInput = true) +{ + const float value = held ? 1.0f : 0.0f; + if (type == 4) processor.springHold.store(value); + else if (type == 5) processor.shimmerHold.store(value); + else if (type == 6) processor.nonlinearHold.store(value); + else if (type == 15) processor.magneticHold.store(value); + else if (type == 16) processor.positionedHold.store(value); + else if (type >= 17 && type <= 20) + processor.ambientControls[static_cast(type - 17)][9].store(held ? acceptsInput ? 1.0f : 2.0f : 0.0f); // 1 = Hold accepting input, 2 = Freeze. + else processor.freezeMode.store(value); +} + +inline void setDecay(OpenStudioReverb& processor, int type, bool extended) +{ + processor.decayTime.store(extended ? (type == 6 ? 2.0f : 20.0f) : .25f); + if (type == 19) processor.ambientControls[2][8].store(extended ? 50.0f : 1.0f); + if (type >= 27) processor.retroControls[static_cast(type - 27)][0].store(extended ? 70.0f : .25f); + if (type == 6) + { + processor.creativeControls[1].store(extended ? .95f : 0.0f); + processor.creativeControls[2].store(extended ? 20.0f : .25f); + processor.creativeControls[3].store(1.0f); + } + // Delay-based spaces have independent feedback rather than this Decay knob. + // A finite feedback target is explicit on release; it is never a Hold clear. + if (!extended && type == 15) processor.echoRoomControls[0][3].store(.2f); + if (!extended && type >= 12 && type <= 14) processor.spatialControls[static_cast(type - 12)][1].store(.2f); +} + +inline bool configure(OpenStudioReverb& processor, const Options& options) +{ + auto layout = processor.getBusesLayout(); + const auto channels = options.channels == 1 ? juce::AudioChannelSet::mono() : juce::AudioChannelSet::stereo(); + layout.inputBuses.set(0, channels); layout.outputBuses.set(0, channels); + const bool layoutPass = processor.setBusesLayout(layout); + processor.selectAlgorithm(options.type); + processor.wetLevel.store(1); processor.dryLevel.store(0); processor.outputTrimDb.store(options.outputDb); + processor.reverbReturnMode.store(static_cast(options.returnMode)); processor.reverbCharacterMode.store(static_cast(options.processing)); + processor.shimmerScheduling.store(options.spreadShimmer ? 1.0f : 0.0f); + processor.ambBody.store(options.vowelBody); + processor.nonlinearShape.store(static_cast(options.nonlinearShape)); + processor.nonlinearModulation.store(options.nonlinearMotion); + processor.nonlinearRate.store(options.nonlinearMotionRate); + processor.tailSpillover.store(0); + processor.holdInputModes[static_cast(options.type)].store(options.heldAcceptsInput ? 1.0f : 0.0f); + if (options.type == 4) processor.springControls[0].store(1); // Qualify the prepared dispersive engine. + setDecay(processor, options.type, true); + processor.prepareToPlay(options.rate, 256); + return layoutPass; +} + +inline void processChecked(OpenStudioReverb& processor, juce::AudioBuffer& block, + juce::MidiBuffer& midi, Measurement& measurement) +{ +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations = ReverbCompatibilityHeapProbe::frees = 0; + ReverbCompatibilityHeapProbe::previous = _CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook); + ReverbCompatibilityHeapProbe::active = true; +#endif + processor.processBlock(block, midi); +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active = false; + _CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous); + measurement.allocations += ReverbCompatibilityHeapProbe::allocations; + measurement.frees += ReverbCompatibilityHeapProbe::frees; + measurement.heapMeasured = true; +#endif + ++measurement.callbacks; +} + +inline Measurement measure(const Options& options) +{ + Measurement measurement; + auto regular = std::make_unique(true); + auto irregular = std::make_unique(true); + measurement.prepared = configure(*regular, options) && configure(*irregular, options); + if (!measurement.prepared) return measurement; + const int outputs = regular->getTotalNumOutputChannels(); + measurement.prepared = outputs == irregular->getTotalNumOutputChannels() && outputs >= options.channels && outputs <= 4; + if (!measurement.prepared) return measurement; + + constexpr int windowSize = 1024; + juce::AudioBuffer reference(outputs, windowSize), variant(outputs, windowSize); + // One deterministic periodic table is prepared outside callbacks. Source + // indexing depends on absolute sample position, never callback boundaries. + juce::AudioBuffer excitation(2, juce::roundToInt(options.rate)); + juce::uint32 random = 0x8f1735u; + for (int sample = 0; sample < excitation.getNumSamples(); ++sample) + { + random = random * 1664525u + 1013904223u; + const float noise = static_cast(random >> 8) / 8388608.0f - 1; + const double time = static_cast(sample) / options.rate; + const float tone = static_cast(std::sin(juce::MathConstants::twoPi * 223 * time) + + .47 * std::sin(juce::MathConstants::twoPi * 977 * time) + + .21 * std::sin(juce::MathConstants::twoPi * 3001 * time)); + excitation.setSample(0, sample, .06f * noise + .035f * tone); + excitation.setSample(1, sample, -.047f * noise + .023f * tone); + } + const int holdStart = juce::roundToInt(options.rate * (options.boundedProbe ? .12 : 1)); + const int sourceEnd = options.hold ? juce::roundToInt(options.rate * (options.boundedProbe ? .32 : 31)) : holdStart; + const int release = options.hold ? juce::roundToInt(options.rate * (options.boundedProbe ? .52 : 61)) : -1; + const int returnSwitch=options.liveReturnSwitch?juce::roundToInt(options.rate*3):-1; + // Fresh finite long-tail capture covers the conservative declared allowance + // plus two seconds. Hold is 30 s with input followed by 30 s with zero input. + const double naturalTail = regular->getTailLengthSeconds(); + const int length = options.hold ? juce::roundToInt(options.rate * (options.boundedProbe ? .92 : 73)) + : juce::roundToInt(options.rate * (1 + naturalTail + 2)); + measurement.seconds = static_cast(length) / options.rate; + measurement.declaredReleaseTail = naturalTail; + juce::MidiBuffer midi; + const std::array partitions {1, 17, 63, 257, 1021}; + size_t partitionIndex = 0; + for (int start = 0; start < length;) + { + if (options.hold && start == holdStart) + { + setHold(*regular, options.type, true, options.heldAcceptsInput); setHold(*irregular, options.type, true, options.heldAcceptsInput); + } + if (options.hold && start == release) + { + setHold(*regular, options.type, false); setHold(*irregular, options.type, false); + setDecay(*regular, options.type, false); setDecay(*irregular, options.type, false); + measurement.declaredReleaseTail = regular->getTailLengthSeconds(); + } + if(start==returnSwitch){regular->reverbReturnMode.store(1);irregular->reverbReturnMode.store(1);} + int count = juce::jmin(windowSize, length - start); + for (const int event : {holdStart, sourceEnd, release,returnSwitch}) + if (start < event) count = juce::jmin(count, event - start); + reference.clear(); variant.clear(); + for (int sample = 0; sample < count; ++sample) + if (start + sample < sourceEnd) + for (int channel = 0; channel < options.channels; ++channel) + { + const float value = excitation.getSample(channel, (start + sample) % excitation.getNumSamples()); + reference.setSample(channel, sample, value); variant.setSample(channel, sample, value); + measurement.inputEnergy += static_cast(value) * value; + } + const auto processWindow = [&] (OpenStudioReverb& processor, juce::AudioBuffer& buffer, bool irregularBlocks) + { + for (int offset = 0; offset < count;) + { + const int chunk = juce::jmin(irregularBlocks ? partitions[partitionIndex++ % partitions.size()] : 256, count - offset); + std::array pointers {}; + for (int channel = 0; channel < outputs; ++channel) + pointers[static_cast(channel)] = buffer.getWritePointer(channel, offset); + juce::AudioBuffer view(pointers.data(), outputs, chunk); + processChecked(processor, view, midi, measurement); + offset += chunk; + } + }; + processWindow(*regular, reference, false); processWindow(*irregular, variant, true); + if(options.type==6) + { + const auto& generator=OpenStudioReverbCompatibilityRegressionAccess::nonlinearBudget(*regular); + const auto& late=OpenStudioReverbCompatibilityRegressionAccess::nonlinearLateBudget(*regular); + measurement.nonlinearStoredPeak=juce::jmax(measurement.nonlinearStoredPeak,static_cast(generator.storedPeak())); + measurement.nonlinearLateStoredPeak=juce::jmax(measurement.nonlinearLateStoredPeak,static_cast(late.storedPeak())); + measurement.nonlinearBudgetInterventions=juce::jmax(measurement.nonlinearBudgetInterventions,generator.interventionCount()); + measurement.nonlinearLateBudgetInterventions=juce::jmax(measurement.nonlinearLateBudgetInterventions,late.interventionCount()); + } + else if(options.type==4) + { + const auto& budget=OpenStudioReverbCompatibilityRegressionAccess::springBudget(*regular); + measurement.springStoredPeak=juce::jmax(measurement.springStoredPeak,static_cast(budget.storedPeak())); + measurement.springBudgetInterventions=juce::jmax(measurement.springBudgetInterventions,budget.interventionCount()); + } + else if(options.type==17) + { + const auto& budget=OpenStudioReverbCompatibilityRegressionAccess::risingBudget(*regular); + measurement.risingStoredPeak=juce::jmax(measurement.risingStoredPeak,static_cast(budget.storedPeak())); + measurement.risingBudgetInterventions=juce::jmax(measurement.risingBudgetInterventions,budget.interventionCount()); + } + for (int channel = 0; channel < outputs; ++channel) + for (int sample = 0; sample < count; ++sample) + { + const double value = reference.getSample(channel, sample), other = variant.getSample(channel, sample); + measurement.finite = std::isfinite(value) && std::isfinite(other) && measurement.finite; + measurement.peak = juce::jmax(measurement.peak, std::abs(value), std::abs(other)); + measurement.partitionError = juce::jmax(measurement.partitionError, std::abs(value - other)); + measurement.outputEnergy += value * value; + const int position = start + sample; + if (std::abs(value) >= 4 || std::abs(other) >= 4) + { + ++measurement.samplesAtSafetyBound; + const double seconds = static_cast(position) / options.rate; + if (measurement.firstSafetyBoundSeconds < 0 || seconds < measurement.firstSafetyBoundSeconds) + measurement.firstSafetyBoundSeconds = seconds; + const size_t phase = !options.hold ? position < sourceEnd ? 0u : 3u + : position < holdStart ? 0u : position < sourceEnd ? 1u : position < release ? 2u : 3u; + ++measurement.phaseSafetyBoundSamples[phase]; + } + if (position >= length - excitation.getNumSamples()) + { + measurement.finalEnergy += value * value; ++measurement.finalSamples; + } + if (options.hold && position >= juce::jmax(sourceEnd, release - excitation.getNumSamples()) && position < release) + { + measurement.silentHoldEnergy += value * value; ++measurement.silentHoldSamples; + } + const int releaseStart = options.hold ? release : sourceEnd; + if (position >= releaseStart && position < releaseStart + excitation.getNumSamples()) + { + measurement.releaseStartEnergy += value * value; ++measurement.releaseSamples; + } + } + start += count; + } + juce::MemoryBlock firstState, secondState; + regular->getStateInformation(firstState); irregular->getStateInformation(secondState); + measurement.stateParity = firstState == secondState; + return measurement; +} +} + +inline juce::var runReverbExtendedQualification(bool boundedHeapProbe, int finiteType = -1, bool holdHeadroom = false) +{ + using namespace ReverbExtendedRegression; + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", boundedHeapProbe ? "Standalone Reverb bounded callback heap transitions" : "Standalone Reverb extended Hold and tail qualification"); + result->setProperty("profile", holdHeadroom ? "focused_accumulating_hold_headroom" : finiteType >= 0 ? "focused_maximum_finite_tail" : boundedHeapProbe ? "bounded_debug_heap_transitions" : "full_extended_release_qualification"); + result->setProperty("audioQuality", "not_asserted"); + result->setProperty("indefiniteHold", "not_asserted"); + result->setProperty("hardwareDeviceDropouts", "not_asserted"); + result->setProperty("realRecordedVoice", "not_asserted"); + result->setProperty("bound", "Existing final output sanitizer +/-4; samples at this bound are separately reported, not treated as unclipped audio."); + result->setProperty("internalRingAndFdnSaturation", "Infinite write-budget telemetry covers Nonlinear generator/late FDN, dispersive Spring and Rising tank; other internal states are not_asserted"); + juce::Array cases; + bool overall = true, heapMeasured = false; + int allocations = 0, frees = 0; + double partitionError = 0; + const double startedAt = juce::Time::getMillisecondCounterHiRes(); + const auto progressFile = juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-extended/progress.json"); + const bool progressDirectoryReady = progressFile.getParentDirectory().createDirectory().wasOk(); + bool progressWritten = progressDirectoryReady; + const auto checkpoint = [&] (const Options* options, bool rowPass) + { + // Harness-only disk work occurs after the complete measured row, with + // the callback heap probe disabled. Never perform this in processBlock. + auto* progress = new juce::DynamicObject(); + progress->setProperty("profile", result->getProperty("profile")); + progress->setProperty("completedCount", cases.size()); + progress->setProperty("elapsedSeconds", (juce::Time::getMillisecondCounterHiRes() - startedAt) * .001); + const auto now = juce::Time::getCurrentTime(); + progress->setProperty("updatedAt", now.toISO8601(true)); // Includes the explicit local UTC offset. + progress->setProperty("updatedUnixMilliseconds", now.toMilliseconds()); + progress->setProperty("overallPassSoFar", overall); + progress->setProperty("lastRowPass", rowPass); + if (options != nullptr) + { + progress->setProperty("type", options->type); progress->setProperty("sampleRate", options->rate); + progress->setProperty("channels", options->channels); progress->setProperty("processingMode", options->processing); + progress->setProperty("hold", options->hold); progress->setProperty("shimmerScheduling", options->spreadShimmer ? 1 : 0); + progress->setProperty("vowelBody", options->vowelBody); progress->setProperty("nonlinearShape", options->nonlinearShape); + progress->setProperty("nonlinearMotion", options->nonlinearMotion); + progress->setProperty("nonlinearMotionRate", options->nonlinearMotionRate); + progress->setProperty("returnMode", options->returnMode); progress->setProperty("heldAcceptsInput", options->heldAcceptsInput); + progress->setProperty("outputDb", options->outputDb); + } + const juce::var snapshot(progress); + progressWritten = progressDirectoryReady && progressFile.replaceWithText(juce::JSON::toString(snapshot)) && progressWritten; + }; + checkpoint(nullptr, true); + const auto add = [&] (const Options& options) + { + const auto capture = measure(options); + // Absolute floor is numerical silence for this small source; relative + // energy requires >=60 dB RMS reduction after the finite release. It + // is a decay-to-silence safety gate, never a fitted RT60 assertion. + const double finalRms = std::sqrt(capture.finalEnergy / static_cast(juce::jmax(juce::int64{1}, capture.finalSamples))); + const double releaseRms = std::sqrt(capture.releaseStartEnergy / static_cast(juce::jmax(juce::int64{1}, capture.releaseSamples))); + const double silentHoldRms = std::sqrt(capture.silentHoldEnergy / static_cast(juce::jmax(juce::int64{1}, capture.silentHoldSamples))); + const bool released = finalRms <= releaseRms * .001 + 1e-7; + const bool pass = capture.prepared && capture.finite && capture.peak <= 4 && capture.inputEnergy > 0 + && capture.outputEnergy > 0 && capture.stateParity && capture.partitionError <= 2e-6 + && (options.boundedProbe || released) && capture.allocations == 0 && capture.frees == 0 + && (!holdHeadroom || (capture.samplesAtSafetyBound==0 && capture.nonlinearStoredPeak<4 && capture.nonlinearLateStoredPeak<4 + && capture.springStoredPeak<4 && capture.risingStoredPeak<2)); + auto* row = new juce::DynamicObject(); + row->setProperty("kind", options.boundedProbe ? "bounded_hold_release_callback_heap_transition" : options.hold ? options.heldAcceptsInput ? "sixty_second_infinite_hold_and_finite_release" : "sixty_second_freeze_hold_and_finite_release" : "maximum_decay_declared_tail_capture"); + row->setProperty("type", options.type); row->setProperty("processingMode", options.processing); + row->setProperty("shimmerScheduling", options.spreadShimmer ? 1 : 0); row->setProperty("vowelBody", options.vowelBody); + row->setProperty("nonlinearShape", options.nonlinearShape); row->setProperty("nonlinearMotion", options.nonlinearMotion); + row->setProperty("nonlinearMotionRate", options.nonlinearMotionRate); + row->setProperty("returnMode", options.returnMode); row->setProperty("heldAcceptsInput", options.heldAcceptsInput); + row->setProperty("outputDb", options.outputDb); + row->setProperty("liveOriginalToCalibratedAtThreeSeconds",options.liveReturnSwitch); + row->setProperty("sampleRate", options.rate); row->setProperty("channels", options.channels); + row->setProperty("seconds", capture.seconds); row->setProperty("holdWithInputSeconds", options.hold ? options.boundedProbe ? .2 : 30 : 0); + row->setProperty("holdWithZeroInputSeconds", options.hold ? options.boundedProbe ? .2 : 30 : 0); + row->setProperty("releaseObservationSeconds", options.hold ? options.boundedProbe ? .4 : 12 : capture.seconds - 1); + row->setProperty("declaredReleaseTailSeconds", capture.declaredReleaseTail); + row->setProperty("inputEnergy", capture.inputEnergy); row->setProperty("outputEnergy", capture.outputEnergy); + row->setProperty("silentHoldFinalRmsDiagnostic", silentHoldRms); row->setProperty("releaseStartRms", releaseRms); + row->setProperty("finalRms", finalRms); row->setProperty("releasedToNumericalSilence", options.boundedProbe ? juce::var("not_asserted_in_bounded_probe") : juce::var(released)); + row->setProperty("finite", capture.finite); row->setProperty("peak", capture.peak); + row->setProperty("samplesAtSafetyBoundDiagnostic", capture.samplesAtSafetyBound); + row->setProperty("firstFinalSafetyClipSecondsDiagnostic", capture.firstSafetyBoundSeconds); + if(options.type==6) + { + row->setProperty("infiniteGeneratorMaximumStoredSample",capture.nonlinearStoredPeak); + row->setProperty("infiniteLateMaximumStoredSample",capture.nonlinearLateStoredPeak); + row->setProperty("infiniteGeneratorBudgetInterventions",static_cast(capture.nonlinearBudgetInterventions)); + row->setProperty("infiniteLateBudgetInterventions",static_cast(capture.nonlinearLateBudgetInterventions)); + row->setProperty("noFinalOrInfiniteStorageGuardHits",capture.samplesAtSafetyBound==0&&capture.nonlinearStoredPeak<4&&capture.nonlinearLateStoredPeak<4); + } + else if(options.type==4) + { + row->setProperty("infiniteSpringMaximumStoredSample",capture.springStoredPeak); + row->setProperty("infiniteSpringBudgetInterventions",static_cast(capture.springBudgetInterventions)); + } + else if(options.type==17) + { + row->setProperty("infiniteRisingMaximumStoredSample",capture.risingStoredPeak); + row->setProperty("infiniteRisingBudgetInterventions",static_cast(capture.risingBudgetInterventions)); + } + auto* phaseClips = new juce::DynamicObject(); + phaseClips->setProperty("initialExcitation", capture.phaseSafetyBoundSamples[0]); + phaseClips->setProperty("heldWithSourceInput", capture.phaseSafetyBoundSamples[1]); + phaseClips->setProperty("heldWithZeroSourceInput", capture.phaseSafetyBoundSamples[2]); + phaseClips->setProperty("finiteRelease", capture.phaseSafetyBoundSamples[3]); + row->setProperty("phaseFinalSafetyClipSamplesDiagnostic", juce::var(phaseClips)); + row->setProperty("maxPartitionError", capture.partitionError); row->setProperty("parameterStateParity", capture.stateParity); + row->setProperty("callbackCount", capture.callbacks); row->setProperty("callbackHeapMeasured", capture.heapMeasured); + row->setProperty("callbackAllocations", capture.allocations); row->setProperty("callbackFrees", capture.frees); + row->setProperty("pass", pass); cases.add(juce::var(row)); overall = overall && pass; + allocations += capture.allocations; frees += capture.frees; heapMeasured = heapMeasured || capture.heapMeasured; + partitionError = juce::jmax(partitionError, capture.partitionError); + checkpoint(&options, pass); + }; + // Maximum finite tail allowance for every current selector, including the + // Vintage, convolution and finite-reflection voices without Hold support. + if (holdHeadroom) + { + Options options; options.hold = true; + for(const int type:{4,6,17})for (const int mode : {0, 1}) + for (const bool acceptsInput : {false, true}) + { + options.type=type; + options.returnMode = mode; options.heldAcceptsInput = acceptsInput; add(options); + } + options.type=6; + options.returnMode = 1; options.heldAcceptsInput = true; options.outputDb = -30; add(options); + options.returnMode=0;options.outputDb=0;options.liveReturnSwitch=true; + for(const int type:{4,6,17}){options.type=type;add(options);} + } + if (!boundedHeapProbe && !holdHeadroom) for (int type = 0; type < OpenStudioReverb::standaloneTypeCount; ++type) + { + if (finiteType >= 0 && type != finiteType) continue; + Options options; options.type = type; + if (finiteType == 6) + { + // Equal tap energy bounds the loop; different envelope spectra and + // moving reads still require actual release qualification. Motion + // 1 uses the highest supported rate, not a scalar feedback estimate. + options.nonlinearMotionRate = 8; + for (int shape = 0; shape < 8; ++shape) + for (const float motion : {0.0f, 1.0f}) + { + options.nonlinearShape = shape; options.nonlinearMotion = motion; add(options); + } + } + else add(options); + } + // Independent prepared families/cores and ambient excitation laws. Vintage, + // convolution, spatial image and finite Retro reflections have no Infinite + // Hold semantics and are covered by the finite-tail rows above instead. + if (finiteType < 0 && !holdHeadroom) for (const int type : {0, 2, 4, 5, 6, 12, 15, 16, 17, 18, 19, 20, 21, 24, 27, 30, 39}) + for (const double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (const int channels : {1, 2}) + { + Options options; options.type = type; options.rate = rate; options.channels = channels; options.hold = true; + options.boundedProbe = boundedHeapProbe; + add(options); + if (type == 2 || type == 19 || type == 21 || type == 24 || type == 27 || type == 30 || type == 39) + { + options.processing = 1; add(options); + } + if (type == 5) { options.spreadShimmer = true; add(options); } + if (type == 20) { options.vowelBody = .5f; add(options); } + } + result->setProperty("pass", overall); result->setProperty("cases", cases); result->setProperty("caseCount", cases.size()); + result->setProperty("maxPartitionError", partitionError); result->setProperty("callbackHeapMeasured", heapMeasured); + result->setProperty("callbackAllocations", allocations); result->setProperty("callbackFrees", frees); + result->setProperty("progressCheckpointWritten", progressWritten); + result->setProperty("progressCheckpoint", progressFile.getFullPathName()); + result->setProperty("scope", holdHeadroom + ? "Focused accumulating Hold headroom,48k stereo: dispersive Spring, Nonlinear and Rising x Original/Calibrated Return x Freeze/Infinite, Nonlinear Calibrated Infinite Output-30dB, and all three Infinite families with a live Original-to-Calibrated return change. Every row has1s excitation,30s held with continuing source,30s held with zero source,12s finite release; Freeze rejects new held source. Zero final +/-4 guard hits and measured Infinite write peaks below their storage guards are assertions. Other internal states and subjective quality remain not_asserted. Regular256 and irregular1/17/63/257/1021 callback output/state parity and finite release remain asserted." + : finiteType >= 0 + ? "Focused actual wrapper maximum Nonlinear finite tail:8 shapes x Motion0/1 at maximum8Hz rate,48k stereo;1s deterministic excitation then declared finite tail plus2s; regular256 versus irregular1/17/63/257/1021 callbacks. Final1s RMS must fall60dB relative to first1s tail plus1e-7 floor. This is numerical release qualification, not all control combinations, acoustic RT60 or sound quality." + : boundedHeapProbe + ? "Bounded actual wrapper callback allocation probe: independent Hold families/cores at44.1/48/96/192k mono/stereo, .2s Hold with input then.2s Hold with zero input then.4s release. Same prepared transitions/partitions as full qualification; long stability and release-to-silence are not asserted by this profile. Debug CRT heap only." + : "Opt-in streaming actual wrapper: all41 maximum finite tails at48k stereo; independent Hold families/cores at44.1/48/96/192k mono/stereo, 30s ongoing excitation then30s zero input then12s finite release; regular256 versus irregular1/17/63/257/1021 callbacks with prepared256. Debug CRT callback heap only; device/dropout and sonic quality are not asserted."); + return result; +} + +inline juce::var checkReverbExtendedQualification() { return runReverbExtendedQualification(false); } +inline juce::var checkReverbExtendedHeapTransitions() { return runReverbExtendedQualification(true); } +inline juce::var checkReverbExtendedNonlinearTail() { return runReverbExtendedQualification(false, 6); } +inline juce::var checkReverbExtendedHoldHeadroom() { return runReverbExtendedQualification(false, 6, true); } diff --git a/Source/FreePluginReverbFactoryRegression.h b/Source/FreePluginReverbFactoryRegression.h new file mode 100644 index 0000000..167c12c --- /dev/null +++ b/Source/FreePluginReverbFactoryRegression.h @@ -0,0 +1,399 @@ +#pragma once +#include "BuiltInReverbFactoryPresets.h" +#include +#include +#include +#include + +inline juce::String reverbFactoryHistoricalHash(const juce::ValueTree& state, bool includeIR) +{ + auto historical = state.createCopy(); + for (const char* id : { "shimmerScheduling", "ambBody", "reverbControlMode" }) + historical.removeProperty(id, nullptr); + if (!includeIR) historical.removeProperty("irData", nullptr); + juce::MemoryBlock bytes; + { juce::MemoryOutputStream stream(bytes, false); historical.writeToStream(stream); } + return juce::SHA256(bytes.getData(), bytes.getSize()).toHexString(); +} + +inline bool reverbFactoryHistoricalDefaults(const juce::ValueTree& state) +{ + for (const char* id : { "shimmerScheduling", "ambBody", "reverbControlMode" }) + if (!state[id].isDouble() || static_cast(state[id]) != 0.0) return false; + return true; +} + +struct ReverbFactoryHistoricalIRCheck +{ + bool pass = false; + juce::uint32 maximumULP = 0; +}; + +inline ReverbFactoryHistoricalIRCheck checkReverbFactoryHistoricalIR(const juce::var& data) +{ + ReverbFactoryHistoricalIRCheck result; + constexpr int frames = 57600; + const auto* bytes = data.getBinaryData(); + if (bytes == nullptr || bytes->getSize() != 20u + 2u * frames * sizeof(float)) return result; + juce::MemoryInputStream stream(*bytes, false); + if (stream.readInt() != 0x4f534952 || stream.readDouble() != 48000.0 + || stream.readInt() != 2 || stream.readInt() != frames) return result; + + // Frozen 2026-10-10 generated-room recipe, independent of the production + // generator. Double exp supplies a reference rounded to float. libm expf + // varies by a final ULP (two after multiplication) between Windows/glibc; + // every sample must stay within two ULPs, with silence and tap positions exact. + juce::AudioBuffer reference(2, frames); + juce::uint64 seed = 0x4f534952; + float filtered[2] {}; + for (int sample = 0; sample < frames; ++sample) + for (int channel = 0; channel < 2; ++channel) + { + seed = (seed * 0x5deece66dULL + 11u) & 0xffffffffffffULL; + const float noise = juce::jmin(static_cast(static_cast(seed >> 16)) + / 4294967296.0f, 1.0f - std::numeric_limits::epsilon()); + filtered[channel] += .35f * ((noise * 2 - 1) - filtered[channel]); + const float exponent = -6.9f * static_cast(sample) / 57600.0f; + reference.setSample(channel, sample, sample < 480 ? 0.0f + : filtered[channel] * .025f * static_cast(std::exp(static_cast(exponent)))); + } + reference.setSample(0, 480, .45f); + reference.setSample(1, 617, .4f); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < frames; ++sample) + { + const float actual = stream.readFloat(), expected = reference.getSample(channel, sample); + if (!std::isfinite(actual) || std::signbit(actual) != std::signbit(expected)) return result; + juce::uint32 actualBits = 0, expectedBits = 0; + std::memcpy(&actualBits, &actual, sizeof(actual)); + std::memcpy(&expectedBits, &expected, sizeof(expected)); + const auto distance = actualBits > expectedBits ? actualBits - expectedBits : expectedBits - actualBits; + result.maximumULP = juce::jmax(result.maximumULP, distance); + const bool exact = sample < 480 || (channel == 0 && sample == 480) || (channel == 1 && sample == 617); + if (distance > (exact ? 0u : 2u)) return result; + } + result.pass = true; + return result; +} + +// Only persisted family memories are classified here. Live aliases such as +// decayTime/nonlinearShape describe the selected bank and are checked separately. +inline int reverbFactoryMemoryOwner(const juce::String& property) +{ + const auto indexed = [&property](const char* prefix, int first, int count) + { + // Check long numeric indices first (retro10 must not match retro1). + for (int slot = count - 1; slot >= 0; --slot) + if (property.startsWith(juce::String(prefix) + juce::String(slot))) return first + slot; + return -1; + }; + for (const auto& group : std::array, 9> {{ + { "ambient", 17, 4 }, { "echoRoom", 15, 2 }, { "spatialLowShelf", 12, 3 }, + { "spatialDelayCapacity", 12, 3 }, { "spatial", 12, 3 }, { "clear", 24, 3 }, + { "retro", 27, 14 }, { "drifting", 21, 3 }, { "vintage", 10, 2 } }}) + { + const int owner = indexed(std::get<0>(group), std::get<1>(group), std::get<2>(group)); + if (owner >= 0) return owner; + } + constexpr std::array studioTypes { 0, 1, 3, 8, 9 }; + for (int slot = 0; slot < 5; ++slot) + if (property.startsWith("studio" + juce::String(slot)) || property.startsWith("studioTone" + juce::String(slot))) + return studioTypes[static_cast(slot)]; + if (property == "roomCharacter") return 0; + if (property.startsWith("plate")) return 2; + if (property.startsWith("spring")) return 4; + if (property == "shimmerRouting" || property == "shimmerVoiceEngine" || property == "shimmerHold" + || property == "shimmerScheduling") return 5; + for (const char* id : { "nonlinearFeedback", "nonlinearLateDecay", "nonlinearLateLevel", "nonlinearDiffusion", + "nonlinearModulation", "nonlinearRate", "nonlinearHold" }) + if (property == id) return 6; + if (property.startsWith("ir")) return 7; + if (property == "ambBody") return 20; + return -1; +} + +inline bool reverbFactoryEnumsExact(OpenStudioReverb& processor) +{ + bool valid = true; + const auto check = [&valid](float value) { valid = std::isfinite(value) && value == std::round(value) && valid; }; + for (int type = 0; type < OpenStudioReverb::standaloneTypeCount; ++type) + { check(processor.getBankValue(type, 8)); check(processor.getBankValue(type, 13)); } + for (size_t slot = 0; slot < processor.studioEngines.size(); ++slot) + { check(processor.studioEngines[slot].load()); check(processor.studioToneControls[slot][0].load()); } + for (size_t slot = 0; slot < processor.vintageColour.size(); ++slot) + { check(processor.vintageColour[slot].load()); check(processor.vintageConversion[slot].load()); check(processor.vintageTankRate[slot].load()); } + for (const auto& controls : processor.spatialControls) + { check(controls[2].load()); check(controls[3].load()); } + for (const auto& controls : processor.echoRoomControls) + { check(controls[1].load()); check(controls[2].load()); check(controls[5].load()); } + for (const auto& controls : processor.ambientControls) + { check(controls[1].load()); check(controls[6].load()); check(controls[7].load()); check(controls[9].load()); } + for (const auto& controls : processor.retroControls) check(controls[5].load()); + for (size_t field = 0; field < 3; ++field) check(processor.springControls[field].load()); + check(processor.plateEngine.load()); check(processor.plateCharacter.load()); check(processor.roomCharacter.load()); + check(processor.creativeControls[0].load()); check(processor.shimmerVoiceEngine.load()); check(processor.shimmerScheduling.load()); + return valid; +} + +inline juce::var checkReverbFactoryPresets() +{ + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Explicit standalone reverb factory voices"); + bool states = true, finite = true, unchanged = true, mixLock = true, files = true; + bool coverage = true, distinctProfiles = true, collectionMemories = true, oldVoices = true, otherMemories = true, enumControls = true; + std::array covered {}; + juce::StringArray profileFingerprints; + juce::Array cases; + juce::StringArray names, encodedStates; + auto defaults = std::make_unique(true); + auto embedded = std::make_unique(false); + juce::MemoryBlock defaultBefore, embeddedBefore; + defaults->getStateInformation(defaultBefore); + embedded->getStateInformation(embeddedBefore); + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-factory/responses"); + files = directory.createDirectory().wasOk(); + const auto defaultTree = juce::ValueTree::readFromData(defaultBefore.getData(), defaultBefore.getSize()); + juce::MemoryBlock collectionBytes; + collectionMemories = OpenStudioReverbFactoryPresets::buildState(OpenStudioReverbFactoryPresets::collection.name, collectionBytes); + auto collection = std::make_unique(true); + collection->setStateInformation(collectionBytes.getData(), static_cast(collectionBytes.getSize())); + const auto collectionTree = juce::ValueTree::readFromData(collectionBytes.getData(), collectionBytes.getSize()); + // Full hashes captured from the eight Windows portable factory files on + // 2026-10-10, before the opt-in controls. Keep these original witnesses; + // procedural expf PCM is not guaranteed bit-identical across platforms. + constexpr std::array historicalHashes { + "a155d04bd12aa719c763644e2bf142f8c04fca9fc366c81e286f0eb5ed1c351a", + "4b3c9e1ad4ce62f74ebf661f34e505b785a48df721a661dabf5081fcb503556d", + "fbee1f26a5fe12279c67002faeac4028992e0ea4b5e7579ae932197ec805d192", + "6428f50a8148545cc524ee729b3d21b5d29d414009e770e6781e561c3c224d8a", + "25d8df3419069403dfa81ad6f404f84d4e3a8d10f3e59c51935eb62c4cd3bf19", + "63d8c4e4a233d8eb150fed7d55d5cf5b2037586a698a0e32168007b449bbb580", + "e3c4049ede2a86d9b1259e65560ff8ae33edb00bacdb16c0895c881f77eb633f", + "ab362d62a8667794e28e4d70d9f5ee787479a602fb57fa53cc30d5985634ec83" + }; + // Derived from those same archived states after verifying every original + // hash above, excluding only irData and the three asserted default additions. + // Values, var types, property order, unknown properties, children and IR + // shape/metadata remain byte-exact; the generated PCM is checked separately. + constexpr std::array historicalControlHashes { + "fa803164b19e942209c3c5dcef84b669d960b6de4cd20f6bac34bef21a66e21f", + "a82e22930d57c234ca51cf3aab78904e7de2c0b63e434018c3ab11d75d9c3fca", + "766a5615cdacc63f9c1be550cd31d07593e684b638f2fe3dce52029eb5f0762d", + "6b094a21cd23825d364b4acf4fa05a2c4af443c7ef372ca02fe469e2438b0af5", + "f410235cda4d15664741040306fbaf6e1ea1594581bb1437a5a36bf602087415", + "cf2d3e5ad0e1137355e9fe757600f07ba999d7ba9673984cb5a1be453db02dda", + "d5f185f03c4745f378f4bc1d69c6327728eeebd24bb73eae3cbfbcf0d0b3a866", + "272ba87038d63319859c1fa701af5506bd47f6f5fc3a495ce6e80e30be9aa724" + }; + const auto defaultIR = defaultTree["irData"]; + const auto historicalIR = checkReverbFactoryHistoricalIR(defaultIR); + bool negativeControls = historicalIR.pass; + if (const auto* originalIR = defaultIR.getBinaryData()) + { + auto corrupted = *originalIR; + static_cast(corrupted.getData())[12] = 1; // Reject mono/header changes. + negativeControls = !checkReverbFactoryHistoricalIR(juce::var(corrupted)).pass && negativeControls; + corrupted = *originalIR; + corrupted.setSize(corrupted.getSize() - sizeof(float)); + negativeControls = !checkReverbFactoryHistoricalIR(juce::var(corrupted)).pass && negativeControls; + for (const float replacement : { .1f, std::numeric_limits::quiet_NaN() }) + { + corrupted = *originalIR; + juce::uint32 bits = 0; + std::memcpy(&bits, &replacement, sizeof(bits)); + bits = juce::ByteOrder::swapIfBigEndian(bits); + std::memcpy(static_cast(corrupted.getData()) + 20 + 1000 * sizeof(float), &bits, sizeof(bits)); + negativeControls = !checkReverbFactoryHistoricalIR(juce::var(corrupted)).pass && negativeControls; + } + } + else negativeControls = false; + size_t existingIndex = 0; + + for (const auto& voice : OpenStudioReverbFactoryPresets::voices) + { + juce::MemoryBlock state, repeated; + const bool built = OpenStudioReverbFactoryPresets::buildState(voice.name, state) + && OpenStudioReverbFactoryPresets::buildState(voice.name, repeated); + const auto nameKey = juce::String(voice.name).toLowerCase(); + const auto encoded = state.toBase64Encoding(); + states = built && state == repeated && ! names.contains(nameKey) + && ! encodedStates.contains(encoded) && states; + names.add(nameKey); encodedStates.add(encoded); + auto restored = std::make_unique(true); + restored->setStateInformation(state.getData(), static_cast(state.getSize())); + juce::MemoryBlock roundTrip; + restored->getStateInformation(roundTrip); + const auto tree = juce::ValueTree::readFromData(state.getData(), state.getSize()); + const bool isCollection = voice.kind == OpenStudioReverbFactoryPresets::Kind::Collection; + const bool isIndividual = voice.kind == OpenStudioReverbFactoryPresets::Kind::Individual; + const bool enumValid = (!isIndividual && !isCollection) || reverbFactoryEnumsExact(*restored); + enumControls = enumValid && enumControls; + const bool refined = isCollection || (isIndividual ? OpenStudioReverbFactoryPresets::refinedProfile(voice.type) + : voice.type == 2 || voice.type == 19 || voice.type >= 21); + bool valid = built && tree.hasType("OpenStudioReverb") && state == roundTrip + && restored->algorithm.load() == static_cast(voice.type) + && restored->outputTrimDb.load() == voice.outputTrimDb + && restored->reverbReturnMode.load() == 1 + && restored->reverbCharacterMode.load() == (refined ? 1.0f : 0.0f); + for (size_t field = 0; field < voice.controls.size(); ++field) + valid = valid && restored->getBankValue(voice.type, static_cast(field)) == voice.controls[field]; + for (int bank = 0; bank < OpenStudioReverb::standaloneTypeCount; ++bank) + if (!isCollection && bank != voice.type) + for (int field = 0; field < OpenStudioReverb::bankControlCount; ++field) + valid = valid && restored->getBankValue(bank, field) == defaults->getBankValue(bank, field); + states = valid && states; + bool memoryValid = true; + if (!isCollection) + for (int property = 0; property < defaultTree.getNumProperties(); ++property) + { + const auto id = defaultTree.getPropertyName(property); + const int owner = reverbFactoryMemoryOwner(id.toString()); + if (owner >= 0 && owner != voice.type) + memoryValid = tree.getProperty(id) == defaultTree.getProperty(id) && memoryValid; + } + otherMemories = memoryValid && otherMemories; + bool legacyStable = true; + bool fullHistoricalBytesMatch = true; + if (voice.kind == OpenStudioReverbFactoryPresets::Kind::Existing) + { + const auto controlsHash = reverbFactoryHistoricalHash(tree, false); + fullHistoricalBytesMatch = existingIndex < historicalHashes.size() + && reverbFactoryHistoricalHash(tree, true) == historicalHashes[existingIndex]; + legacyStable = reverbFactoryHistoricalDefaults(tree) && historicalIR.pass + && tree["irData"] == defaultIR && existingIndex < historicalControlHashes.size() + && controlsHash == historicalControlHashes[existingIndex]; + #if JUCE_WINDOWS + legacyStable = fullHistoricalBytesMatch && legacyStable; + #endif + if (existingIndex == 0) + { + auto changed = tree.createCopy(); changed.setProperty("algorithm", 1.0, nullptr); + negativeControls = reverbFactoryHistoricalHash(changed, false) != controlsHash && negativeControls; + changed = tree.createCopy(); changed.setProperty("algorithm", 0, nullptr); // Same value, different var type. + negativeControls = reverbFactoryHistoricalHash(changed, false) != controlsHash && negativeControls; + changed = tree.createCopy(); changed.setProperty("unrecognisedState", 1, nullptr); + negativeControls = reverbFactoryHistoricalHash(changed, false) != controlsHash && negativeControls; + changed = tree.createCopy(); changed.setProperty("shimmerScheduling", 1.0, nullptr); + negativeControls = !reverbFactoryHistoricalDefaults(changed) && negativeControls; + } + ++existingIndex; + oldVoices = legacyStable && oldVoices; + } + if (isIndividual) + { + covered[static_cast(voice.type)] = true; + // Fingerprint only the authored common profile, without name/type. + // This rejects copied default tables but makes no audible assertion. + juce::MemoryBlock fields; + { juce::MemoryOutputStream stream(fields, false); for (float value : voice.controls) stream.writeFloat(value); } + const auto fingerprint = juce::SHA256(fields.getData(), fields.getSize()).toHexString(); + distinctProfiles = !profileFingerprints.contains(fingerprint) && distinctProfiles; + profileFingerprints.add(fingerprint); + for (int field = 0; field < OpenStudioReverb::bankControlCount; ++field) + collectionMemories = collection->getBankValue(voice.type, field) == restored->getBankValue(voice.type, field) && collectionMemories; + for (int property = 0; property < tree.getNumProperties(); ++property) + { + const auto id = tree.getPropertyName(property); + if (reverbFactoryMemoryOwner(id.toString()) == voice.type) + collectionMemories = collectionTree.getProperty(id) == tree.getProperty(id) && collectionMemories; + } + collection->selectAlgorithm(voice.type); + for (size_t field = 0; field < voice.controls.size(); ++field) + collectionMemories = collection->getBankValue(voice.type, static_cast(field)) == voice.controls[field] && collectionMemories; + } + + // Exercise the native factory Mix Lock transformation. Output remains + // part of the new voice; only the established six mix fields are locked. + defaults->wetLevel.store(.17f); defaults->dryLevel.store(.81f); + defaults->sendMode.store(1); defaults->mixLock.store(1); + defaults->insertWet.store(.23f); defaults->insertDry.store(.64f); + defaults->outputTrimDb.store(-11); + auto lockedState = state; + OpenStudioReverbFactoryPresets::preserveLockedMix(*defaults, lockedState); + auto locked = std::make_unique(true); + locked->setStateInformation(lockedState.getData(), static_cast(lockedState.getSize())); + mixLock = locked->wetLevel.load() == .17f && locked->dryLevel.load() == .81f + && locked->sendMode.load() == 1 && locked->mixLock.load() == 1 + && locked->insertWet.load() == .23f && locked->insertDry.load() == .64f + && locked->outputTrimDb.load() == voice.outputTrimDb && mixLock; + defaults->setStateInformation(defaultBefore.getData(), static_cast(defaultBefore.getSize())); + + // Finite nonzero network response is deterministic sanity only. This + // wet-only override does not establish perceived difference or quality. + constexpr int blockSize = 128, length = 96000; + restored->wetLevel.store(1); restored->dryLevel.store(0); restored->sendMode.store(1); + restored->setRateAndBufferSizeDetails(48000, blockSize); + restored->prepareToPlay(48000, blockSize); + const int channels = juce::jmax(2, restored->getTotalNumOutputChannels()); + juce::AudioBuffer block(channels, blockSize), output(2, length); + juce::MidiBuffer midi; + for (int start = 0; start < 4800; start += blockSize) + { block.clear(); restored->processBlock(block, midi); } + bool sane = true; + double energy = 0, peak = 0; + for (int start = 0; start < length; start += blockSize) + { + block.clear(); + if (start == 0) { block.setSample(0, 0, .25f); block.setSample(1, 0, .25f); } + restored->processBlock(block, midi); + for (int channel = 0; channel < 2; ++channel) + { + output.copyFrom(channel, start, block, channel, 0, blockSize); + for (int sample = 0; sample < blockSize; ++sample) + { + const double value = block.getSample(channel, sample); + sane = sane && std::isfinite(value); + energy += value * value; peak = juce::jmax(peak, std::abs(value)); + } + } + } + const auto filename = juce::String(voice.name).replace("Factory - ", "").replaceCharacter(' ', '-') + ".wav"; + const bool written = writeProbeWave(directory.getChildFile(filename), output, 48000); + finite = sane && energy > 0 && finite; files = written && files; + auto* row = new juce::DynamicObject(); + row->setProperty("name", voice.name); row->setProperty("type", voice.type); + row->setProperty("fullStateRoundTrip", valid); row->setProperty("finiteNonzero", sane && energy > 0); + row->setProperty("collection", isCollection); row->setProperty("canonicalProfile", isIndividual); + row->setProperty("authoredEnumsExact", enumValid); + row->setProperty("nonselectedMemoriesUnchanged", memoryValid); row->setProperty("historicalFactoryStateStable", legacyStable); + if (voice.kind == OpenStudioReverbFactoryPresets::Kind::Existing) + row->setProperty("historicalWindowsFullBytesMatchDiagnostic", fullHistoricalBytesMatch); + row->setProperty("outputTrimDb", voice.outputTrimDb); row->setProperty("peakDiagnostic", peak); + row->setProperty("energyDiagnostic", energy); row->setProperty("file", filename); + row->setProperty("fileWritten", written); cases.add(row); + restored->releaseResources(); + } + collection->selectAlgorithm(0); + juce::MemoryBlock browsedCollection; + collection->getStateInformation(browsedCollection); + collectionMemories = collectionMemories && browsedCollection == collectionBytes; + for (bool present : covered) coverage = present && coverage; + coverage = coverage && profileFingerprints.size() == 41 && cases.size() == 50 && existingIndex == 8; + juce::MemoryBlock defaultAfter, embeddedAfter, untouched; + defaults->getStateInformation(defaultAfter); embedded->getStateInformation(embeddedAfter); + auto fresh = std::make_unique(true); fresh->getStateInformation(untouched); + unchanged = defaultBefore == defaultAfter && defaultBefore == untouched && embeddedBefore == embeddedAfter; + juce::MemoryBlock rejected; + states = states && ! OpenStudioReverbFactoryPresets::buildState("Unknown factory voice", rejected) + && ! OpenStudioReverbFactoryPresets::isFactory("OpenStudio NAM Rack", OpenStudioReverbFactoryPresets::voices[0].name); + result->setProperty("cases", cases); result->setProperty("portableFullStates", states); + result->setProperty("constructorAndEmbeddedDefaultsUnchanged", unchanged); + result->setProperty("everyTypeAuthored", coverage); result->setProperty("distinctParameterProfiles", distinctProfiles); + result->setProperty("collectionContainsEveryProfileAndBrowsesWithoutMutation", collectionMemories); + result->setProperty("historicalEightFactoryStatesUnchanged", oldVoices); + result->setProperty("historicalGeneratedIRRecipe", historicalIR.pass); + result->setProperty("historicalGeneratedIRMaximumULP", static_cast(historicalIR.maximumULP)); + result->setProperty("historicalCompatibilityNegativeControls", negativeControls); + result->setProperty("historicalComparison", "Exact historical state excluding procedural IR PCM; all PCM samples within two ULPs of the frozen recipe, exact header/silence/direct taps. Windows additionally retains original full-byte hashes."); + result->setProperty("individualNonselectedFamilyMemoriesUnchanged", otherMemories); + result->setProperty("authoredEnumsExact", enumControls); + result->setProperty("collectionSchema", describeFreePluginForRegression(*collection)); + result->setProperty("sixFieldMixLock", mixLock); result->setProperty("finiteNonzeroResponses", finite); + result->setProperty("responseFiles", files); result->setProperty("directory", directory.getFullPathName()); + result->setProperty("pass", states && unchanged && mixLock && finite && files && coverage + && distinctProfiles && collectionMemories && oldVoices && otherMemories && enumControls && negativeControls); + result->setProperty("audioQuality", "not_asserted"); result->setProperty("loudnessMatch", "not_asserted"); + result->setProperty("perceptualDifference", "not_asserted"); + result->setProperty("window", "Two-second diagnostic responses can truncate long tails"); + return result; +} diff --git a/Source/FreePluginReverbHoldRegression.h b/Source/FreePluginReverbHoldRegression.h index 4e2b1c9..ea487c0 100644 --- a/Source/FreePluginReverbHoldRegression.h +++ b/Source/FreePluginReverbHoldRegression.h @@ -17,8 +17,8 @@ inline juce::var checkReverbHoldPolicies() juce::AudioBuffer output(2,length),block(2,blockSize);juce::MidiBuffer midi;juce::uint32 random=123; for(int start=0;startshimmerHold.store(1);else reverb->freezeMode.store(1);} - if(start==releaseHold){reverb->shimmerHold.store(0);reverb->freezeMode.store(0);} + if(start==startHold){if(type==5)reverb->shimmerHold.store(1);else if(type==6)reverb->nonlinearHold.store(1);else reverb->freezeMode.store(1);} + if(start==releaseHold){reverb->shimmerHold.store(0);reverb->nonlinearHold.store(0);reverb->freezeMode.store(0);} int count=juce::jmin(blockSize,length-start);for(int event:{startHold,extraStart,extraEnd,releaseHold})if(start, 3> makeReverbListeningSources() +{ + constexpr int rate = 48000, length = rate * 8; + std::array, 3> sources; + for (auto& source : sources) { source.setSize(2, length); source.clear(); } + uint32_t random = 0x97abc012u; + for (int sample = 0; sample < rate * 3; ++sample) + { + const double time = static_cast(sample) / rate; + const int hitSample = sample % (rate / 2); + random = random * 1664525u + 1013904223u; + const float noise = static_cast(random >> 8) / 8388608.0f - 1; + const float percussion = .16f * noise * std::exp(-static_cast(hitSample) / 500) + + .16f * static_cast(std::sin(6.283185307179586 * 90 * time)) * std::exp(-static_cast(hitSample) / 1800); + const double gate = std::sin(juce::MathConstants::pi * juce::jlimit(0.0, 1.0, time / 3)); + float vowel = 0; + for (int harmonic = 1; harmonic <= 20; ++harmonic) + { + const double frequency = harmonic * 145.0; + const double envelope = .15 + std::exp(-std::pow((frequency - 700) / 200, 2.0)) + + .7 * std::exp(-std::pow((frequency - 1200) / 240, 2.0)); + vowel += static_cast(.07 * envelope / harmonic * gate + * std::sin(6.283185307179586 * frequency * time + .018 * harmonic * std::sin(6.283185307179586 * 5 * time))); + } + for (int channel = 0; channel < 2; ++channel) + { + sources[0].setSample(channel, sample, percussion * (channel == 0 ? 1.0f : .83f)); + sources[1].setSample(channel, sample, vowel); + const double phase = channel * .19; + const float chord = static_cast(.07 * gate * (std::sin(6.283185307179586 * 220 * time + phase) + + .6 * std::sin(6.283185307179586 * 277.1826 * time - phase) + + .4 * std::sin(6.283185307179586 * 329.6276 * time + phase))); + sources[2].setSample(channel, sample, chord); + } + } + return sources; +} + +inline juce::var renderReverbListeningPack() +{ + constexpr int rate = 48000, frames = 128, length = rate * 8; + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-listening/raw"); + bool sane = directory.createDirectory().wasOk(); + juce::Array rows; + const std::array materials {"percussion", "synthetic-vowel", "sustained-chord"}; + const auto sources = makeReverbListeningSources(); + for (size_t material = 0; material < sources.size(); ++material) + sane = writeProbeWave(directory.getChildFile(juce::String("source-") + materials[material] + ".wav"), sources[material], rate) && sane; + + const auto render = [&] (int type, const juce::String& voiceName, int refined, bool stationary) + { + for (size_t material = 0; material < materials.size(); ++material) + { + auto processor = std::make_unique(true); + if (voiceName.isNotEmpty()) + { + juce::MemoryBlock state; + sane = OpenStudioReverbFactoryPresets::buildState(voiceName, state) && sane; + processor->setStateInformation(state.getData(), static_cast(state.getSize())); + } + else { processor->selectAlgorithm(type); processor->reverbCharacterMode.store(static_cast(refined)); processor->reverbReturnMode.store(0); } + if (stationary) + { + if (type == 2) processor->plateModulation.store(0); + if (type == 19) processor->ambientControls[2][4].store(0); + if (type >= 21 && type <= 23) { processor->driftingControls[static_cast(type - 21)][1].store(0); processor->driftingControls[static_cast(type - 21)][2].store(0); } + if (type >= 24 && type <= 26) processor->clearControls[static_cast(type - 24)][2].store(0); + if (type >= 27) processor->retroControls[static_cast(type - 27)][3].store(0); + } + processor->wetLevel.store(1); processor->dryLevel.store(0); processor->sendMode.store(1); + processor->outputTrimDb.store(0); + processor->prepareToPlay(rate, frames); + const int channels = juce::jmax(2, processor->getTotalNumOutputChannels()); + juce::AudioBuffer block(channels, frames), output(2, length); + juce::MidiBuffer midi; + for (int start = 0; start < rate / 10; start += frames) { block.clear(); processor->processBlock(block, midi); } + double energy = 0, peak = 0; bool finite = true; + for (int start = 0; start < length; start += frames) + { + block.clear(); + for (int channel = 0; channel < 2; ++channel) block.copyFrom(channel, 0, sources[material], channel, start, frames); + processor->processBlock(block, midi); + for (int channel = 0; channel < 2; ++channel) + { + output.copyFrom(channel, start, block, channel, 0, frames); + for (int sample = 0; sample < frames; ++sample) + { + const double value = block.getSample(channel, sample); + finite = std::isfinite(value) && finite; energy += value * value; peak = juce::jmax(peak, std::abs(value)); + } + } + } + const juce::String label = voiceName.isNotEmpty() ? voiceName.replace("Factory - ", "Factory-").replaceCharacter(' ', '-') + : juce::String("type-") + juce::String(type).paddedLeft('0', 2) + (stationary ? "-Motion-off" : "") + (refined ? "-Refined" : "-Original"); + const auto file = label + "-" + materials[material] + ".wav"; + const bool written = writeProbeWave(directory.getChildFile(file), output, rate); + juce::MemoryBlock state; processor->getStateInformation(state); + const bool stateWritten = directory.getChildFile(label + ".state").replaceWithData(state.getData(), state.getSize()); + auto* row = new juce::DynamicObject(); row->setProperty("label", label); row->setProperty("type", type); + row->setProperty("material", materials[material]); row->setProperty("file", file); + row->setProperty("characterMode", processor->reverbCharacterMode.load()); row->setProperty("fileWritten", written); + row->setProperty("returnMode", processor->reverbReturnMode.load()); + row->setProperty("finiteNonzero", finite && energy > 0); row->setProperty("rms", std::sqrt(energy / (2 * length))); + row->setProperty("peak", peak); rows.add(row); + sane = written && stateWritten && finite && energy > 0 && sane; + } + }; + for (int type = 0; type < OpenStudioReverb::standaloneTypeCount; ++type) render(type, {}, 0, false); + for (const int type : {2, 19, 21, 24, 28, 38}) + for (const int refined : {0, 1}) render(type, {}, refined, true); + for (const auto& voice : OpenStudioReverbFactoryPresets::voices) render(voice.type, voice.name, 0, false); + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Standalone Reverb listening pack"); + result->setProperty("pass", sane); result->setProperty("cases", rows); result->setProperty("directory", directory.getFullPathName()); + result->setProperty("sampleRate", rate); result->setProperty("seconds", 8); result->setProperty("excitationSeconds", 3); + result->setProperty("input", "Deterministic generated percussion, harmonic synthetic vowel and sustained chord; no recorded voice"); + result->setProperty("mix", "wet=1, dry=0, output=0; factory wet/output overrides disabled for comparison"); + result->setProperty("audioQuality", "not_asserted"); result->setProperty("tailCompleteness", "not_asserted"); + result->setProperty("levelMatching", "Run tools/prepare_reverb_listening.py; whole-file stereo RMS is diagnostic, not perceived loudness"); + return result; +} diff --git a/Source/FreePluginReverbOutputRegression.h b/Source/FreePluginReverbOutputRegression.h new file mode 100644 index 0000000..516edb2 --- /dev/null +++ b/Source/FreePluginReverbOutputRegression.h @@ -0,0 +1,145 @@ +#pragma once + +inline juce::var checkReverbOutputGain() +{ + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Standalone reverb final output gain"); + auto original = std::make_unique(true); + auto restored = std::make_unique(true); + bool statePass = setFreePluginParamForRegression(*original, "outputTrimDb", 6); + original->selectAlgorithm(40); + juce::MemoryBlock saved, again; + original->getStateInformation(saved); + restored->setStateInformation(saved.getData(), static_cast(saved.getSize())); + restored->getStateInformation(again); + statePass = statePass && saved == again && restored->outputTrimDb.load() == 6; + restored->selectAlgorithm(0); + statePass = statePass && restored->outputTrimDb.load() == 6; + restored->selectSendMode(true); + statePass = statePass && setFreePluginParamForRegression(*restored, "outputTrimDb", -3) + && restored->wetLevel.load() == 1 && restored->dryLevel.load() == 0; + restored->selectSendMode(false); + statePass = statePass && restored->outputTrimDb.load() == -3; + auto oldTree = juce::ValueTree::readFromData(saved.getData(), saved.getSize()); + oldTree.removeProperty("outputTrimDb", nullptr); + juce::MemoryBlock oldState; + { juce::MemoryOutputStream stream(oldState, false); oldTree.writeToStream(stream); } + restored->setStateInformation(oldState.getData(), static_cast(oldState.getSize())); + const bool legacyDefault = restored->outputTrimDb.load() == 0; + bool rangePass = true; + for (const float normalized : {0.0f, .25f, .5f, 1.0f}) + rangePass = setFreePluginNormalizedForRegression(*original, "outputTrimDb", normalized) + && std::abs(original->outputTrimDb.load() - (-60 + 72 * normalized)) < 1e-5f && rangePass; + rangePass = setFreePluginParamForRegression(*original, "outputTrimDb", 80) + && original->outputTrimDb.load() == 12 && rangePass; + rangePass = setFreePluginParamForRegression(*original, "outputTrimDb", -100) + && original->outputTrimDb.load() == -60 && rangePass; + auto embedded = std::make_unique(); + const bool embeddedUnchanged = !setFreePluginParamForRegression(*embedded, "outputTrimDb", 6) + && !setFreePluginNormalizedForRegression(*embedded, "outputTrimDb", 1); + const auto schema = describeFreePluginForRegression(*original); + const auto parameters = schema["parameters"]; + const bool appended = parameters.size() == 1090 && parameters[1083]["id"].toString() == "reconstructedPeaks" + && parameters[1084]["id"].toString() == "outputTrimDb" && static_cast(parameters[1084]["automatable"]) + && static_cast(parameters[1084]["defaultValue"]) == 0; + + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-output-tests"); + juce::AudioBuffer ir(4,2048); ir.clear(); + for (int ch = 0; ch < 4; ++ch) ir.setSample(ch, 30 + ch * 71, .3f - .04f * ch); + const auto file = directory.getChildFile("four-paths.wav"); + bool fixturePass = writeProbeWave(file, ir, 48000); + double gainError = 0, meterError = 0, partitionError = 0; + bool unityPass = true, finite = true, nonzero = true; + const auto render = [&] (int type, float trim, int blockSize, bool change, bool old) + { + auto processor = std::make_unique(true); + if (old) processor->setStateInformation(oldState.getData(), static_cast(oldState.getSize())); + processor->selectAlgorithm(type); + processor->outputTrimDb.store(trim); + processor->wetLevel.store(change ? 0.0f : .6f); + processor->dryLevel.store(change ? 1.0f : .4f); + if (type == 7) + { + auto* shape = new juce::DynamicObject(); + shape->setProperty("normalise", false); shape->setProperty("outputLayout", 1); + fixturePass = processor->convolutionSpace.loadFile(file) && processor->convolutionSpace.edit(shape) && fixturePass; + juce::MemoryBlock state; processor->getStateInformation(state); + processor->setStateInformation(state.getData(), static_cast(state.getSize())); + } + constexpr int length = 14400, event = 4800; + processor->prepareToPlay(48000, blockSize); + const int channels = juce::jmax(2, processor->getTotalNumOutputChannels()); + juce::AudioBuffer output(channels, length), block(channels, blockSize); + juce::MidiBuffer midi; + for (int start = 0; start < length;) + { + if (change && start == event) processor->outputTrimDb.store(12); + int count = juce::jmin(blockSize, length - start); + if (start < event) count = juce::jmin(count, event - start); + block.setSize(channels, count, false, false, true); block.clear(); + for (int i = 0; i < count; ++i) + { + const float signal = change ? .01f : .02f * std::sin(.071f * static_cast(start + i)); + block.setSample(0, i, signal); block.setSample(1, i, signal * -.7f); + } + processor->processBlock(block, midi); + for (int ch = 0; ch < channels; ++ch) + { + output.copyFrom(ch, start, block, ch, 0, count); + for (int i = 0; i < count; ++i) finite = std::isfinite(block.getSample(ch, i)) && finite; + } + for (int ch = 0; ch < 2; ++ch) + meterError = juce::jmax(meterError, std::abs(static_cast(processor->outputPeaksDb[static_cast(ch)].load()) + - juce::Decibels::gainToDecibels(block.getMagnitude(ch, 0, count), -100.0f))); + start += count; + } + return output; + }; + for (int type : {0,2,4,7,10,21,24,40}) + { + const auto unity = render(type, 0, 127, false, false), old = render(type, 0, 127, false, true); + for (float db : {-60.0f,-6.0f,12.0f}) + { + const auto trimmed = render(type, db, 127, false, false); + const float gain = juce::Decibels::decibelsToGain(db); + for (int ch = 0; ch < unity.getNumChannels(); ++ch) + for (int i = 0; i < unity.getNumSamples(); ++i) + { + gainError = juce::jmax(gainError, std::abs(static_cast(trimmed.getSample(ch,i)) - unity.getSample(ch,i) * gain)); + unityPass = unity.getSample(ch,i) == old.getSample(ch,i) && unityPass; + } + } + for (int ch = 0; ch < (type == 7 ? unity.getNumChannels() : 2); ++ch) + nonzero = unity.getMagnitude(ch, 0, unity.getNumSamples()) > 1e-6f && nonzero; + } + const auto narrowCapture = render(0, 0, 17, true, false), wideCapture = render(0, 0, 511, true, false); + for (int ch = 0; ch < 2; ++ch) for (int i = 0; i < narrowCapture.getNumSamples(); ++i) + partitionError = juce::jmax(partitionError, std::abs(static_cast(narrowCapture.getSample(ch,i) - wideCapture.getSample(ch,i)))); + const float initial = narrowCapture.getSample(0,4799), first = narrowCapture.getSample(0,4800), final = narrowCapture.getSample(0,5759); + bool rampPass = initial == .01f && first > initial && first < .011f + && std::abs(final - .01f * juce::Decibels::decibelsToGain(12.0f)) < 1e-7f; + for (int i = 4801; i < 5760; ++i) rampPass = narrowCapture.getSample(0,i) >= narrowCapture.getSample(0,i-1) && rampPass; + + auto track = std::make_unique(); track->prepareToPlay(48000,64); + auto automated = std::make_unique(true); auto* reverb = automated.get(); + bool automationPass = track->addTrackFX(std::move(automated),48000,64); + track->setForceAutomationReadForProcessing(true); + auto target = track->resolveAutomationTarget("builtin_track_0_outputTrimDb",true); + automationPass = target.has_value() && automationPass; + if (target) + { + target->list->setMode(AutomationMode::Read); target->list->setPoints({{0,.25f},{1,.25f}}); + track->setCurrentBlockPosition(.2); juce::AudioBuffer audio(2,64); audio.clear(); juce::MidiBuffer midi; + track->processBlock(audio,midi); automationPass = automationPass && reverb->outputTrimDb.load() == -42; + } + result->setProperty("pass", statePass && legacyDefault && rangePass && embeddedUnchanged && appended && fixturePass + && gainError < 1e-7 && meterError < 1e-5 && unityPass && finite && nonzero && partitionError < 1e-7 && rampPass && automationPass); + result->setProperty("stateAndSendRecall",statePass); result->setProperty("legacyDefault",legacyDefault); + result->setProperty("rangeAndNormalizedAutomation",rangePass); result->setProperty("embeddedUnchanged",embeddedUnchanged); + result->setProperty("appendedDescriptor",appended); result->setProperty("gainError",gainError); + result->setProperty("postGainMeterError",meterError); result->setProperty("oldStateUnityParity",unityPass); + result->setProperty("fourOutputFixture",fixturePass); result->setProperty("nonzeroAllOutputs",nonzero); + result->setProperty("finite",finite); result->setProperty("partitionError",partitionError); + result->setProperty("twentyMillisecondRamp",rampPass); result->setProperty("trackEnvelopeRead",automationPass); + result->setProperty("audioQuality","not_asserted"); return result; +} diff --git a/Source/FreePluginReverbRefinementRegression.h b/Source/FreePluginReverbRefinementRegression.h new file mode 100644 index 0000000..8d3f6e0 --- /dev/null +++ b/Source/FreePluginReverbRefinementRegression.h @@ -0,0 +1,104 @@ +#pragma once + +inline juce::var checkReverbRefinementState() +{ + auto source = std::make_unique(true); + auto restored = std::make_unique(true); + const bool defaultOriginal = source->reverbCharacterMode.load() == 0; + bool roundTrip = setFreePluginParamForRegression(*source, "reverbCharacterMode", 1); + juce::MemoryBlock saved, repeated; + source->getStateInformation(saved); + restored->setStateInformation(saved.getData(), static_cast(saved.getSize())); + restored->getStateInformation(repeated); + roundTrip = roundTrip && saved == repeated && restored->reverbCharacterMode.load() == 1; + restored->selectAlgorithm(19); restored->selectSendMode(true); + const bool global = restored->reverbCharacterMode.load() == 1; + auto old = juce::ValueTree::readFromData(saved.getData(), saved.getSize()); + old.removeProperty("reverbCharacterMode", nullptr); + juce::MemoryBlock oldBytes; + { juce::MemoryOutputStream stream(oldBytes, false); old.writeToStream(stream); } + restored->setStateInformation(oldBytes.getData(), static_cast(oldBytes.getSize())); + const bool migration = restored->reverbCharacterMode.load() == 0; + bool range = setFreePluginParamForRegression(*source, "reverbCharacterMode", 20) + && source->reverbCharacterMode.load() == 1 + && setFreePluginNormalizedForRegression(*source, "reverbCharacterMode", 0) + && source->reverbCharacterMode.load() == 0; + auto embedded = std::make_unique(); + const bool embeddedUnchanged = !setFreePluginParamForRegression(*embedded, "reverbCharacterMode", 1) + && !setFreePluginNormalizedForRegression(*embedded, "reverbCharacterMode", 1); + const auto schema = describeFreePluginForRegression(*source); + const auto params = schema["parameters"]; + const bool appended = params.size() == 1090 && params[1084]["id"].toString() == "outputTrimDb" + && params[1085]["id"].toString() == "reverbCharacterMode" && static_cast(params[1085]["automatable"]) + && static_cast(params[1085]["defaultValue"]) == 0; + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Standalone Refined processing state and migration"); + result->setProperty("defaultOriginal", defaultOriginal); result->setProperty("portableRoundTrip", roundTrip); + result->setProperty("globalAcrossTypeAndSend", global); result->setProperty("missingPropertyOriginal", migration); + result->setProperty("rangeAndNormalized", range); result->setProperty("appendOnlyIndex1085", appended); + result->setProperty("embeddedUnchanged", embeddedUnchanged); + result->setProperty("pass", defaultOriginal && roundTrip && global && migration && range && appended && embeddedUnchanged); + result->setProperty("audioQuality", "not_asserted"); + return result; +} + +// Timings cover warmed whole wrappers with independent active stereo inputs. +// Scheduling, the host graph and hardware-device callbacks are not simulated. +inline juce::var profileReverbInstances() +{ + juce::Array rows; + bool sane = true; + for (const int type : {2, 5, 19, 24, 38}) + for (const int instances : {1, 4, 8}) + for (const int refined : {0, 1}) + { + constexpr double rate = 48000; + constexpr int frames = 128, observations = 256; + std::vector> processors; + std::vector> buffers; + processors.reserve(static_cast(instances)); buffers.reserve(static_cast(instances)); + for (int index = 0; index < instances; ++index) + { + auto processor = std::make_unique(true); + processor->selectAlgorithm(type); processor->reverbCharacterMode.store(static_cast(refined)); + processor->wetLevel.store(1); processor->dryLevel.store(0); + processor->prepareToPlay(rate, frames); + buffers.emplace_back(2, frames); processors.push_back(std::move(processor)); + } + juce::MidiBuffer midi; + std::vector times; times.reserve(observations); + int overruns = 0; + for (int iteration = -128; iteration < observations; ++iteration) + { + for (int index = 0; index < instances; ++index) + for (int sample = 0; sample < frames; ++sample) + { + const float value = .04f * std::sin(.047f * static_cast((iteration + 128) * frames + sample + index)); + buffers[static_cast(index)].setSample(0, sample, value); + buffers[static_cast(index)].setSample(1, sample, value * .8f); + } + const auto start = juce::Time::getHighResolutionTicks(); + for (int index = 0; index < instances; ++index) + processors[static_cast(index)]->processBlock(buffers[static_cast(index)], midi); + const double micros = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - start) * 1e6; + if (iteration >= 0) { times.push_back(micros); overruns += micros > frames / rate * 1e6 ? 1 : 0; } + for (const auto& buffer : buffers) + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < frames; ++sample) + sane = std::isfinite(buffer.getSample(channel, sample)) && sane; + } + std::sort(times.begin(), times.end()); + auto* row = new juce::DynamicObject(); row->setProperty("type", type); row->setProperty("instances", instances); + row->setProperty("refined", refined); row->setProperty("medianUs", times[times.size() / 2]); + row->setProperty("returnLevel", "Calibrated"); + row->setProperty("p95Us", times[times.size() * 95 / 100]); row->setProperty("p99Us", times[times.size() * 99 / 100]); + row->setProperty("maxUs", times.back()); row->setProperty("budgetUs", frames / rate * 1e6); + row->setProperty("deadlineOverruns", overruns); rows.add(row); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Standalone Reverb instance cost screen"); + result->setProperty("pass", sane); result->setProperty("finiteOutput", sane); result->setProperty("cases", rows); + result->setProperty("sampleRate", 48000); result->setProperty("blockSize", 128); + result->setProperty("cpu", juce::SystemStats::getCpuModel()); result->setProperty("timing", "diagnostic_only"); + result->setProperty("deviceDropouts", "not_asserted"); result->setProperty("audioQuality", "not_asserted"); + return result; +} diff --git a/Source/FreePluginReverbRetirementRegression.h b/Source/FreePluginReverbRetirementRegression.h new file mode 100644 index 0000000..d9c6c96 --- /dev/null +++ b/Source/FreePluginReverbRetirementRegression.h @@ -0,0 +1,210 @@ +#pragma once +#include + +namespace ReverbRetirementRegression +{ +struct Capture +{ + juce::AudioBuffer audio; + int dormantAt = -1; + bool dormantAtEnd = false; +}; + +template +void setHold(typename Family::Settings& settings, bool hold) +{ + if constexpr (std::is_same_v + || std::is_same_v) settings.freeze = hold; + else if constexpr (std::is_same_v + || std::is_same_v) settings.hold = hold; + else juce::ignoreUnused(settings, hold); +} + +template +Capture render(double rate, int mode, int blockSize, bool retire, + bool spillover, bool reselect = false, bool fresh = false, + bool holdBeforeRetirement = false, bool cancelSpillover = false) +{ + auto engine = std::make_unique(); + engine->prepare(rate, fresh ? -1 : mode); + typename Family::Settings settings; + settings.decay = 2; + const int length = juce::roundToInt(rate * .55); + const int holdAt = juce::roundToInt(rate * .09); + const int offAt = juce::roundToInt(rate * .12); + const int cancelAt = juce::roundToInt(rate * .20); + const int returnAt = juce::roundToInt(rate * .24); + const int newInputAt = juce::roundToInt(rate * .28); + const int resumeSpilloverAt = juce::roundToInt(rate * .31); + Capture capture { juce::AudioBuffer(2, length), -1, false }; + juce::uint32 random = 123; + for (int start = 0; start < length;) + { + int count = juce::jmin(blockSize, length - start); + for (int event : {holdAt, offAt, cancelAt, returnAt, newInputAt, resumeSpilloverAt}) + if (start < event) count = juce::jmin(count, event - start); + const bool active = (!fresh && (!retire || start < offAt)) + || (reselect && start >= returnAt); + setHold(settings, holdBeforeRetirement && start >= holdAt && start < offAt); + engine->configure(active ? mode : -1, settings, + spillover && (!cancelSpillover || start < cancelAt || start >= resumeSpilloverAt)); + for (int sample = start; sample < start + count; ++sample) + { + random = random * 1664525u + 1013904223u; + const float noise = (static_cast(random >> 8) / 8388608.0f - 1) * .1f; + // Inactive input intentionally remains nonzero: it must not be + // recorded into histories and resurrected after reselection. + const bool feed = (!fresh && sample < juce::roundToInt(rate * .08)) + || (sample >= offAt && sample < returnAt) + || (reselect && sample == newInputAt); + const auto wet = engine->process(feed ? noise : 0, feed ? -noise * .7f : 0); + for (int channel = 0; channel < 2; ++channel) + capture.audio.setSample(channel, sample, wet[static_cast(channel)]); + if (retire && sample >= offAt && (!reselect || sample < returnAt) + && capture.dormantAt < 0 && engine->isDormant()) capture.dormantAt = sample; + } + start += count; + } + capture.dormantAtEnd = engine->isDormant(); + return capture; +} + +inline double difference(const juce::AudioBuffer& first, + const juce::AudioBuffer& second, int start = 0) +{ + double result = 0; + for (int channel = 0; channel < 2; ++channel) + for (int sample = start; sample < first.getNumSamples(); ++sample) + result = juce::jmax(result, std::abs(static_cast(first.getSample(channel, sample)) + - second.getSample(channel, sample))); + return result; +} + +inline double energy(const juce::AudioBuffer& audio, int start, int end) +{ + double result = 0; + for (int channel = 0; channel < 2; ++channel) + for (int sample = start; sample < end; ++sample) + { + const double value = audio.getSample(channel, sample); + result += value * value; + } + return result; +} + +template +bool checkFamily(juce::Array& cases, const char* name, int modes, double rate) +{ + bool pass = true; + const int offAt = juce::roundToInt(rate * .12); + const int returnAt = juce::roundToInt(rate * .24); + const int newInputAt = juce::roundToInt(rate * .28); + const int fadeSamples = static_cast(rate * .05); + for (int mode = 0; mode < modes; ++mode) + { + const auto active = render(rate, mode, 127, false, false); + const auto activePartition = render(rate, mode, 512, false, false); + const auto activeRetaining = render(rate, mode, 127, false, true); + const auto muted = render(rate, mode, 127, true, false); + const auto mutedPartition = render(rate, mode, 512, true, false); + const auto retained = render(rate, mode, 127, true, true); + const auto resumed = render(rate, mode, 127, true, false, true); + const auto fresh = render(rate, mode, 127, true, false, true, true); + const auto held = render(rate, mode, 127, true, false, false, false, true); + const auto cancelled = render(rate, mode, 127, true, true, false, false, false, true); + const int cancelledDormantAt = juce::roundToInt(rate * .20) + fadeSamples - 1; + const double partitionError = juce::jmax(difference(active.audio, activePartition.audio), + difference(muted.audio, mutedPartition.audio)); + const double activeError = difference(active.audio, activeRetaining.audio); + const double resumedError = difference(resumed.audio, fresh.audio, returnAt); + const double dormantEnergy = energy(muted.audio, offAt + fadeSamples, muted.audio.getNumSamples()); + const double reselectedSilentEnergy = energy(resumed.audio, returnAt, newInputAt); + const double retainedEnergy = energy(retained.audio, returnAt, newInputAt); + const double reEnabledSpilloverEnergy = energy(cancelled.audio, juce::roundToInt(rate * .31), cancelled.audio.getNumSamples()); + // Clear's periodic oscillator normalization follows its cumulative + // processing counter; reset histories may differ by floating-point + // roundoff from a newly prepared bank, while silence must stay exact. + const bool rowPass = partitionError == 0 && activeError == 0 && resumedError <= 1e-7 + && dormantEnergy == 0 && reselectedSilentEnergy == 0 && retainedEnergy > 0 + && muted.dormantAt == offAt + fadeSamples - 1 && muted.dormantAtEnd + && held.dormantAt == offAt + fadeSamples - 1 && held.dormantAtEnd + && cancelled.dormantAt == cancelledDormantAt && cancelled.dormantAtEnd + && reEnabledSpilloverEnergy == 0 + && !retained.dormantAtEnd; + pass = pass && rowPass; + auto* row = new juce::DynamicObject(); + row->setProperty("family", name); row->setProperty("mode", mode); row->setProperty("sampleRate", rate); + row->setProperty("pass", rowPass); row->setProperty("partitionError", partitionError); + row->setProperty("activeSpilloverParityError", activeError); row->setProperty("freshReselectionError", resumedError); + row->setProperty("dormantAtSample", muted.dormantAt); row->setProperty("expectedDormantAtSample", offAt + fadeSamples - 1); + row->setProperty("dormantOutputEnergy", dormantEnergy); row->setProperty("reselectedSilentEnergy", reselectedSilentEnergy); + row->setProperty("retainedEnergyDiagnostic", retainedEnergy); row->setProperty("heldThenRetired", held.dormantAtEnd); + row->setProperty("cancelledSpilloverDormantAtSample", cancelled.dormantAt); + row->setProperty("expectedCancelledSpilloverDormantAtSample", cancelledDormantAt); + row->setProperty("reEnabledSpilloverEnergy", reEnabledSpilloverEnergy); + cases.add(row); + } + return pass; +} + +inline bool checkVintageRateBanks(juce::Array& cases) +{ + constexpr double rate = 48000; + const int switchAt = juce::roundToInt(rate * .12); + const int fadeSamples = static_cast(rate * .05); + bool pass = true; + for (int mode = 0; mode < 2; ++mode) for (int outgoing = 0; outgoing < 3; ++outgoing) + { + auto engine = std::make_unique(); + engine->prepare(rate, mode); + BuiltInVintageReverb::Settings settings; + settings.tankRate = static_cast(outgoing); + engine->configure(mode, settings, false); + int retiredAt = -1; + for (int sample = 0; sample < switchAt + fadeSamples + 1; ++sample) + { + if (sample == switchAt) + { + settings.tankRate = static_cast((outgoing + 1) % 3); + engine->configure(mode, settings, false); + } + const auto wet = engine->process(sample == 0 ? .1f : 0, 0); + juce::ignoreUnused(wet); + // A 24 kHz incoming bank gets its first processing tick on the + // second host sample; before that, one running bank is the old one. + const auto incoming = static_cast((outgoing + 1) % 3); + if (sample >= switchAt && retiredAt < 0 && engine->runningTankCount(static_cast(mode)) == 1 + && engine->processingFrames(static_cast(mode), incoming) > 0) + retiredAt = sample; + } + const bool rowPass = retiredAt == switchAt + fadeSamples - 1; + pass = pass && rowPass; + auto* row = new juce::DynamicObject(); + row->setProperty("family", "Vintage tank rate"); row->setProperty("mode", mode); + row->setProperty("outgoingRateBank", outgoing); row->setProperty("pass", rowPass); + row->setProperty("retiredAtSample", retiredAt); row->setProperty("expectedRetiredAtSample", switchAt + fadeSamples - 1); + cases.add(row); + } + return pass; +} +} + +inline juce::var checkReverbRetirement() +{ + juce::Array cases; + bool pass = true; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + { + pass = ReverbRetirementRegression::checkFamily(cases, "Studio", 6, rate) && pass; + pass = ReverbRetirementRegression::checkFamily(cases, "Plate", 3, rate) && pass; + pass = ReverbRetirementRegression::checkFamily(cases, "Drifting", 3, rate) && pass; + pass = ReverbRetirementRegression::checkFamily(cases, "Clear", 3, rate) && pass; + pass = ReverbRetirementRegression::checkFamily(cases, "Vintage", 2, rate) && pass; + } + pass = ReverbRetirementRegression::checkVintageRateBanks(cases) && pass; + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Inaudible standalone reverb tank retirement"); + result->setProperty("pass", pass); result->setProperty("cases", cases); + result->setProperty("audioQuality", "not_asserted"); + return result; +} diff --git a/Source/FreePluginReverbReturnIRRegression.h b/Source/FreePluginReverbReturnIRRegression.h new file mode 100644 index 0000000..1955aac --- /dev/null +++ b/Source/FreePluginReverbReturnIRRegression.h @@ -0,0 +1,209 @@ +#pragma once +#include "BuiltInConvolution.h" +#include +#include + +// Kernel identity and crossfade arithmetic only. Shared wrapper EQ, modulation +// and tail-extension history are intentionally outside this isolated oracle. +namespace ReverbReturnIRRegression +{ +inline constexpr int maximumCallbackSamples = 1021; +struct Capture +{ + juce::AudioBuffer audio; + bool prepared = true, finite = true; + double oracleError = 0, outgoingHistoryPeak = 0, crossPeak = 0; + double settledCrossPeak = 0; +}; + +inline double difference(const juce::AudioBuffer& first, + const juce::AudioBuffer& second, int firstChannel = 0) +{ + if (first.getNumChannels() != second.getNumChannels() + || first.getNumSamples() != second.getNumSamples()) + return std::numeric_limits::infinity(); + double maximum = 0; + for (int channel = firstChannel; channel < first.getNumChannels(); ++channel) + for (int sample = 0; sample < first.getNumSamples(); ++sample) + maximum = juce::jmax(maximum, std::abs(static_cast(first.getSample(channel, sample)) + - static_cast(second.getSample(channel, sample)))); + return maximum; +} +} + +inline juce::var checkReverbReturnIRCrossfade() +{ + using namespace ReverbReturnIRRegression; + constexpr int preparedBlock = 128, maximumBlock = maximumCallbackSamples; + constexpr double fadeSeconds = .05; + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-return-ir-tests"); + const auto file = directory.getChildFile("matrix-crossfade.wav"); + juce::AudioBuffer impulse(4, 4096); impulse.clear(); + for (int channel = 0; channel < 4; ++channel) + { + impulse.setSample(channel, 31 + channel * 73, .25f - .035f * static_cast(channel)); + impulse.setSample(channel, 717 + channel * 109, -.09f + .017f * static_cast(channel)); + impulse.setSample(channel, 3071 + channel * 127, .05f - .007f * static_cast(channel)); + } + bool fixture = writeProbeWave(file, impulse, 48000); + BuiltInConvolution generatedSource, customSource; + fixture = generatedSource.selectDefault() && customSource.loadFile(file) && fixture; + auto* shape = new juce::DynamicObject(); + shape->setProperty("normalise", false); shape->setProperty("outputLayout", 1); + fixture = customSource.edit(juce::var(shape)) && fixture; + juce::ValueTree generatedState("IR"), customState("IR"); + generatedSource.save(generatedState); customSource.save(customState); + fixture = static_cast(generatedState["irGeneratedDefault"]) + && !static_cast(customState["irGeneratedDefault"]) && fixture; + + int allocations = 0, frees = 0; + bool heapMeasured = false; + const auto processChecked = [&] (BuiltInConvolution& processor, + juce::AudioBuffer& block, const float* calibration) { +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations = ReverbCompatibilityHeapProbe::frees = 0; + ReverbCompatibilityHeapProbe::previous = _CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook); + ReverbCompatibilityHeapProbe::active = true; +#endif + processor.process(block, 0, 20, 20000, 1, true, false, calibration); +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active = false; + _CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous); + allocations += ReverbCompatibilityHeapProbe::allocations; + frees += ReverbCompatibilityHeapProbe::frees; + heapMeasured = true; +#endif + }; + const auto render = [&] (double rate, bool generatedFirst, bool irregular, int mode) { + Capture capture; + const auto& outgoingState = generatedFirst ? generatedState : customState; + const auto& incomingState = generatedFirst ? customState : generatedState; + BuiltInConvolution dynamic, outgoing, incoming; + capture.prepared = dynamic.restore(outgoingState) && outgoing.restore(outgoingState) + && incoming.restore(incomingState); + for (auto* processor : {&dynamic, &outgoing, &incoming}) processor->prepare(rate, preparedBlock); + const int event = juce::roundToInt(rate * .12), length = juce::roundToInt(rate * .30); + const int settled = event + static_cast(rate * fadeSeconds) + preparedBlock; + capture.audio.setSize(4, length); capture.audio.clear(); + juce::AudioBuffer actual(4, maximumBlock), previous(4, maximumBlock), next(4, maximumBlock); + std::array calibration{}; + calibration.fill(mode == 1 ? 1.0f : 0.0f); + const float* calibrationPointer = mode < 0 ? nullptr : calibration.data(); + juce::SmoothedValue referenceFade; + referenceFade.reset(rate, fadeSeconds); referenceFade.setCurrentAndTargetValue(0); + referenceFade.setTargetValue(1); + const float generatedGain = mode == 1 ? BuiltInReverbReturnLevels::linear[7] : 1.0f; + const float outgoingGain = generatedFirst ? generatedGain : 1.0f; + const float incomingGain = generatedFirst ? 1.0f : generatedGain; + const std::array partitions{1, 17, 63, 257, 1021}; + for (int start = 0, part = 0; start < length; ++part) + { + if (start == event) + capture.prepared = dynamic.restore(incomingState) && capture.prepared; + int count = juce::jmin(irregular ? partitions[static_cast(part % 5)] : preparedBlock, length - start); + if (start < event) count = juce::jmin(count, event - start); + for (auto* block : {&actual, &previous, &next}) + { + block->setSize(4, count, false, false, true); block->clear(); + for (int sample = 0; sample < count; ++sample) + { + const double time = static_cast(start + sample) / rate; + const float left = .025f * static_cast( + std::sin(juce::MathConstants::twoPi * 223 * time) + + .47 * std::sin(juce::MathConstants::twoPi * 977 * time)); + const float right = .019f * static_cast( + std::sin(juce::MathConstants::twoPi * 331 * time) + - .31 * std::sin(juce::MathConstants::twoPi * 1709 * time)); + block->setSample(0, sample, left); block->setSample(1, sample, right); + } + } + processChecked(dynamic, actual, calibrationPointer); + processChecked(outgoing, previous, nullptr); + // Incoming references start with a fresh FIR/FFT history, exactly + // when the prepared replacement is accepted by the live kernel. + if (start >= event) processChecked(incoming, next, nullptr); + for (int sample = 0; sample < count; ++sample) + { + const float blend = start >= event ? referenceFade.getNextValue() : 0.0f; + for (int channel = 0; channel < 4; ++channel) + { + const float before = previous.getSample(channel, sample) * outgoingGain; + const float after = start >= event ? next.getSample(channel, sample) * incomingGain : 0.0f; + const float expected = start >= event ? before * (1 - blend) + after * blend : before; + const float value = actual.getSample(channel, sample); + capture.audio.setSample(channel, start + sample, value); + capture.finite = std::isfinite(value) && std::isfinite(expected) && capture.finite; + capture.oracleError = juce::jmax(capture.oracleError, + std::abs(static_cast(value) - static_cast(expected))); + if (start + sample >= event && start + sample < event + preparedBlock) + capture.outgoingHistoryPeak = juce::jmax(capture.outgoingHistoryPeak, + std::abs(static_cast(previous.getSample(channel, sample)))); + if (channel >= 2) + { + capture.crossPeak = juce::jmax(capture.crossPeak, std::abs(static_cast(value))); + if (start + sample >= settled) + capture.settledCrossPeak = juce::jmax(capture.settledCrossPeak, std::abs(static_cast(value))); + } + } + } + start += count; + } + return capture; + }; + + juce::Array cases; + bool passed = fixture; + double maximumOracleError = 0, maximumPartitionError = 0, maximumUnityError = 0; + double maximumCustomCrossError = 0; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (bool generatedFirst : {true, false}) + { + std::array regular, irregular; + for (int index = 0; index < 3; ++index) + { + const int mode = index - 1; // nullptr, explicit Original, Calibrated. + regular[static_cast(index)] = render(rate, generatedFirst, false, mode); + irregular[static_cast(index)] = render(rate, generatedFirst, true, mode); + } + double oracleError = 0, partitionError = 0; + bool finite = true, ready = true, history = true, cross = true; + for (size_t index = 0; index < regular.size(); ++index) + { + const auto& a = regular[index]; const auto& b = irregular[index]; + oracleError = juce::jmax(oracleError, juce::jmax(a.oracleError, b.oracleError)); + partitionError = juce::jmax(partitionError, difference(a.audio, b.audio)); + finite = a.finite && b.finite && finite; ready = a.prepared && b.prepared && ready; + history = a.outgoingHistoryPeak > 1e-8 && b.outgoingHistoryPeak > 1e-8 && history; + cross = a.crossPeak > 1e-8 && b.crossPeak > 1e-8 && cross; + cross = (generatedFirst ? a.settledCrossPeak > 1e-8 && b.settledCrossPeak > 1e-8 + : a.settledCrossPeak == 0 && b.settledCrossPeak == 0) && cross; + } + const double unityError = juce::jmax(difference(regular[0].audio, regular[1].audio), + difference(irregular[0].audio, irregular[1].audio)); + const double customCrossError = juce::jmax(difference(regular[1].audio, regular[2].audio, 2), + difference(irregular[1].audio, irregular[2].audio, 2)); + maximumOracleError = juce::jmax(maximumOracleError, oracleError); + maximumPartitionError = juce::jmax(maximumPartitionError, partitionError); + maximumUnityError = juce::jmax(maximumUnityError, unityError); + maximumCustomCrossError = juce::jmax(maximumCustomCrossError, customCrossError); + const bool rowPass = ready && finite && history && cross && oracleError < 2e-6 + && partitionError < 2e-6 && unityError == 0 && customCrossError == 0; + auto* row = new juce::DynamicObject(); row->setProperty("sampleRate", rate); + row->setProperty("direction", generatedFirst ? "generated_to_custom_matrix" : "custom_matrix_to_generated"); + row->setProperty("prepared", ready); row->setProperty("finite", finite); + row->setProperty("nonzeroOutgoingHistory", history); row->setProperty("crossPathsActiveAndSettled", cross); + row->setProperty("maxOracleError", oracleError); row->setProperty("maxPartitionError", partitionError); + row->setProperty("maxOriginalUnityError", unityError); row->setProperty("maxCustomCrossGainError", customCrossError); + row->setProperty("pass", rowPass); cases.add(juce::var(row)); passed = rowPass && passed; + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Per-kernel IR return calibration crossfade"); + result->setProperty("pass", passed && allocations == 0 && frees == 0); result->setProperty("fixture", fixture); + result->setProperty("cases", cases); result->setProperty("caseCount", cases.size()); + result->setProperty("maximumOracleError", maximumOracleError); result->setProperty("maximumPartitionError", maximumPartitionError); + result->setProperty("maximumOriginalUnityError", maximumUnityError); result->setProperty("maximumCustomCrossGainError", maximumCustomCrossError); + result->setProperty("callbackHeapMeasured", heapMeasured); result->setProperty("callbackAllocations", allocations); + result->setProperty("callbackFrees", frees); result->setProperty("fadeSeconds", fadeSeconds); + result->setProperty("audioQuality", "not_asserted"); + result->setProperty("scope", "Live generated/custom kernel identity, 50 ms independent reference mix, four paths, Original unity and callback partitioning. EQ and outer filters bypassed; shared wrapper addons excluded. Release heap measurement not asserted."); + return juce::var(result); +} diff --git a/Source/FreePluginReverbReturnRegression.h b/Source/FreePluginReverbReturnRegression.h new file mode 100644 index 0000000..9eea3e5 --- /dev/null +++ b/Source/FreePluginReverbReturnRegression.h @@ -0,0 +1,381 @@ +#pragma once +#include "BuiltInReverbReturnLevels.h" +#include + +// Actual standalone wrapper checks. The scalar references below test routing +// and gain placement, not perceived loudness, audibility or acoustic fidelity. +namespace ReverbReturnRegression +{ +struct Options +{ + double rate = 48000; + int channels = 2, type = 0, mode = 0, nextType = -1; + float wet = 1, dry = 0, amplitude = .003f; + bool irregular = true, live = false, legacy = false, spillover = false; + bool customIR = false, embedded = false; + bool maximumDecay = false, held = false; + const juce::ValueTree* irState = nullptr; +}; +struct Capture +{ + juce::AudioBuffer audio; + bool prepared = true, finite = true; + double peak = 0; +}; +inline double error(const juce::AudioBuffer& first, const juce::AudioBuffer& second, + double scale = 1, int begin = 0) +{ + if (first.getNumChannels() != second.getNumChannels() || first.getNumSamples() != second.getNumSamples()) + return std::numeric_limits::infinity(); + double maximum = 0; + for (int channel = 0; channel < first.getNumChannels(); ++channel) + for (int sample = begin; sample < first.getNumSamples(); ++sample) + maximum = juce::jmax(maximum, std::abs(static_cast(first.getSample(channel, sample)) + - static_cast(second.getSample(channel, sample)) * scale)); + return maximum; +} +inline juce::MemoryBlock bytes(const juce::ValueTree& tree) +{ + juce::MemoryBlock result; + { juce::MemoryOutputStream stream(result, false); tree.writeToStream(stream); } + return result; +} +} + +inline juce::var checkReverbReturnCalibration() +{ + using namespace ReverbReturnRegression; + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Standalone bounded wet-return calibration"); + result->setProperty("audioQuality", "not_asserted"); + result->setProperty("audibility", "not_asserted"); + result->setProperty("dominance", "not_asserted"); + juce::Array cases; + bool overall = true, heapMeasured = false; + int allocations = 0, frees = 0; + double maximumGainError = 0, maximumPartitionError = 0, maximumDryError = 0; + const auto add = [&] (juce::DynamicObject* row, bool pass) { + row->setProperty("pass", pass); cases.add(juce::var(row)); overall = overall && pass; + }; + const auto processChecked = [&] (OpenStudioReverb& processor, juce::AudioBuffer& audio, juce::MidiBuffer& midi) { +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations = ReverbCompatibilityHeapProbe::frees = 0; + ReverbCompatibilityHeapProbe::previous = _CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook); + ReverbCompatibilityHeapProbe::active = true; +#endif + processor.processBlock(audio, midi); +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active = false; + _CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous); + allocations += ReverbCompatibilityHeapProbe::allocations; + frees += ReverbCompatibilityHeapProbe::frees; + heapMeasured = true; +#endif + }; + + auto source = std::make_unique(true); + auto restored = std::make_unique(true); + auto embedded = std::make_unique(false); + bool statePass = source->reverbReturnMode.load() == 1 && embedded->reverbReturnMode.load() == 0; + statePass = setFreePluginParamForRegression(*source, "reverbReturnMode", 0) + && source->reverbReturnMode.load() == 0 && statePass; + juce::MemoryBlock saved, repeated; + source->getStateInformation(saved); + restored->setStateInformation(saved.getData(), static_cast(saved.getSize())); + restored->getStateInformation(repeated); + statePass = saved == repeated && restored->reverbReturnMode.load() == 0 && statePass; + statePass = setFreePluginNormalizedForRegression(*restored, "reverbReturnMode", 1) + && restored->reverbReturnMode.load() == 1 && statePass; + restored->selectAlgorithm(40); restored->selectSendMode(true); + statePass = restored->reverbReturnMode.load() == 1 && statePass; + auto oldTree = juce::ValueTree::readFromData(saved.getData(), saved.getSize()); + oldTree.removeProperty("reverbReturnMode", nullptr); + const auto oldBytes = bytes(oldTree); + restored->setStateInformation(oldBytes.getData(), static_cast(oldBytes.getSize())); + statePass = restored->reverbReturnMode.load() == 0 && statePass; + oldTree.setProperty("reverbReturnMode", std::numeric_limits::quiet_NaN(), nullptr); + const auto invalidBytes = bytes(oldTree); + restored->setStateInformation(invalidBytes.getData(), static_cast(invalidBytes.getSize())); + statePass = restored->reverbReturnMode.load() == 0 && statePass; + statePass = !setFreePluginParamForRegression(*embedded, "reverbReturnMode", 1) + && !setFreePluginNormalizedForRegression(*embedded, "reverbReturnMode", 1) && statePass; + const auto parameters = describeFreePluginForRegression(*source)["parameters"]; + statePass = parameters.size() == 1090 && parameters[1084]["id"].toString() == "outputTrimDb" + && parameters[1085]["id"].toString() == "reverbCharacterMode" + && parameters[1086]["id"].toString() == "reverbReturnMode" + && static_cast(parameters[1086]["automatable"]) + && static_cast(parameters[1086]["defaultValue"]) == 1 && statePass; + auto* stateRow = new juce::DynamicObject(); stateRow->setProperty("kind", "state_and_append_only_migration"); + add(stateRow, statePass); + + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-return-tests"); + juce::AudioBuffer ir(4, 2048); ir.clear(); + for (int channel = 0; channel < 4; ++channel) ir.setSample(channel, 31 + channel * 73, .25f - .035f * channel); + const auto customFile = directory.getChildFile("Studio room (generated).wav"); + bool fixturePass = writeProbeWave(customFile, ir, 48000); + const auto render = [&] (const Options& options) { + auto processor = std::make_unique(!options.embedded); + auto layout = processor->getBusesLayout(); + layout.inputBuses.set(0, options.channels == 1 ? juce::AudioChannelSet::mono() : juce::AudioChannelSet::stereo()); + layout.outputBuses.set(0, options.channels == 1 ? juce::AudioChannelSet::mono() : juce::AudioChannelSet::stereo()); + Capture capture; + capture.prepared = processor->setBusesLayout(layout); + processor->selectAlgorithm(options.type); + processor->reverbReturnMode.store(static_cast(options.mode)); + processor->wetLevel.store(options.wet); processor->dryLevel.store(options.dry); + processor->tailSpillover.store(options.spillover ? 1.0f : 0.0f); + if (options.maximumDecay) + { + processor->decayTime.store(options.type == 6 ? 2.0f : 20.0f); + if (options.type == 4) processor->springControls[0].store(1); // Dispersive, internally capped at 10 s. + if (options.type == 6) + { + processor->creativeControls[1].store(.95f); // Maximum envelope feedback. + processor->creativeControls[2].store(20); processor->creativeControls[3].store(1); + } + if (options.type == 19) processor->ambientControls[2][8].store(50); + if (options.type >= 27) processor->retroControls[static_cast(options.type - 27)][0].store(70); + } + if (options.held) + { + processor->holdInputModes[static_cast(options.type)].store(1); // Accumulate nonzero input in Infinite. + if (options.type == 4) processor->springHold.store(1); + else if (options.type == 5) processor->shimmerHold.store(1); + else if (options.type == 6) processor->nonlinearHold.store(1); + else if (options.type == 19 || options.type == 20) + processor->ambientControls[static_cast(options.type - 17)][9].store(1); + else processor->freezeMode.store(1); + } + if (options.legacy) + { + processor->studioEngines[0].store(0); processor->plateEngine.store(0); + } + if (options.customIR) + { + auto* shape = new juce::DynamicObject(); shape->setProperty("normalise", false); shape->setProperty("outputLayout", 1); + const juce::var settings(shape); + capture.prepared = processor->convolutionSpace.loadFile(customFile) + && capture.prepared; + if (options.irState) capture.prepared = processor->convolutionSpace.restore(*options.irState) && capture.prepared; + capture.prepared = processor->convolutionSpace.edit(settings) && capture.prepared; + } + else if (options.irState) capture.prepared = processor->convolutionSpace.restore(*options.irState) && capture.prepared; + constexpr int preparedBlock = 256, maximumBlock = 1021; + processor->prepareToPlay(options.rate, preparedBlock); + const int length = juce::roundToInt(options.rate * .40), event = juce::roundToInt(options.rate * .16); + const int outputs = processor->getTotalNumOutputChannels(); + capture.audio.setSize(outputs, length); + juce::AudioBuffer block(outputs, maximumBlock); juce::MidiBuffer midi; + const std::array partitions {1,17,63,257,1021}; + for (int start = 0, part = 0; start < length; ++part) + { + if (start == event) + { + if (options.live) processor->reverbReturnMode.store(options.mode == 0 ? 1.0f : 0.0f); + if (options.nextType >= 0) processor->selectAlgorithm(options.nextType); + } + int count = juce::jmin(options.irregular ? partitions[static_cast(part % 5)] : preparedBlock, length - start); + if (start < event) count = juce::jmin(count, event - start); + block.setSize(outputs, count, false, false, true); block.clear(); + for (int sample = 0; sample < count; ++sample) + { + const int position = start + sample; + const bool feed = options.nextType < 0 || position < event; + const double time = static_cast(position) / options.rate; + const float signal = feed ? options.amplitude * static_cast( + std::sin(juce::MathConstants::twoPi * 223 * time) + + .47 * std::sin(juce::MathConstants::twoPi * 977 * time) + + .21 * std::sin(juce::MathConstants::twoPi * 3001 * time)) : 0.0f; + for (int channel = 0; channel < options.channels; ++channel) + block.setSample(channel, sample, signal * (channel == 0 ? 1.0f : -.73f)); + } + processChecked(*processor, block, midi); + for (int channel = 0; channel < outputs; ++channel) + { + capture.audio.copyFrom(channel, start, block, channel, 0, count); + for (int sample = 0; sample < count; ++sample) + { + const float value = block.getSample(channel, sample); + capture.finite = std::isfinite(value) && capture.finite; + capture.peak = juce::jmax(capture.peak, std::abs(static_cast(value))); + } + } + start += count; + } + return capture; + }; + + // Fixed-mode gain placement for every current default voice, plus exact + // dry-only parity. No fitted calibration or perceptual threshold is tested. + for (int type = 0; type < OpenStudioReverb::standaloneTypeCount; ++type) + { + Options options; options.type = type; + const auto original = render(options); options.mode = 1; + const auto calibrated = render(options); + const double gainError = error(calibrated.audio, original.audio, BuiltInReverbReturnLevels::linear[static_cast(type)]); + options.wet = 0; options.dry = .7f; + const auto calibratedDry = render(options); options.mode = 0; + const auto originalDry = render(options); + const double dryError = error(calibratedDry.audio, originalDry.audio); + maximumGainError = juce::jmax(maximumGainError, gainError); + maximumDryError = juce::jmax(maximumDryError, dryError); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "all_default_voice_wet_gain_and_dry_parity"); + row->setProperty("type", type); row->setProperty("maxGainError", gainError); row->setProperty("maxDryError", dryError); + row->setProperty("gainDb", BuiltInReverbReturnLevels::decibels[static_cast(type)]); + add(row, original.prepared && calibrated.prepared && original.finite && calibrated.finite + && original.peak > 0 && calibrated.peak > 0 && gainError < 2e-6 && dryError == 0); + } + + // Shared live fade is measured through nonzero wet audio, at four rates, + // in both main layouts and with callbacks larger than the prepared size. + for (const double rate : {44100.0,48000.0,96000.0,192000.0}) + for (const int channels : {1,2}) + for (const int initialMode : {0,1}) + { + Options options; options.type = 1; options.rate = rate; options.channels = channels; + options.mode = initialMode; options.live = true; + const auto live = render(options); options.irregular = false; + const auto regular = render(options); options.live = false; options.mode = 0; + const auto original = render(options); options.mode = 1; + const auto calibrated = render(options); + const double partitionError = error(live.audio, regular.audio); + const int event = juce::roundToInt(rate * .16), ramp = static_cast(rate * .02); + double rampError = 0; + for (int channel = 0; channel < live.audio.getNumChannels(); ++channel) + for (int sample = 0; sample < live.audio.getNumSamples(); ++sample) + { + const double position = sample < event ? 0.0 : juce::jlimit(0.0, 1.0, + static_cast(sample - event + 1) / ramp); + const double blend = initialMode == 0 ? position : 1 - position; + const double a = original.audio.getSample(channel, sample), b = calibrated.audio.getSample(channel, sample); + rampError = juce::jmax(rampError, std::abs(static_cast(live.audio.getSample(channel, sample)) - (a + blend * (b - a)))); + } + maximumPartitionError = juce::jmax(maximumPartitionError, partitionError); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "twenty_ms_live_mode_fade_and_partition"); + row->setProperty("sampleRate", rate); row->setProperty("channels", channels); row->setProperty("initialMode", initialMode); + row->setProperty("maxRampError", rampError); row->setProperty("maxPartitionError", partitionError); + add(row, live.prepared && live.finite && partitionError < 2e-6 && rampError < 2e-6); + } + + for (const double rate : {44100.0,48000.0,96000.0,192000.0}) + for (const int channels : {1,2}) + for (const int type : {0,2}) + { + Options options; options.type = type; options.legacy = true; options.rate = rate; options.channels = channels; + const auto original = render(options); options.mode = 1; + const auto calibrated = render(options); + const double unchanged = error(calibrated.audio, original.audio); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "legacy_engine_unity"); + row->setProperty("type", type); row->setProperty("sampleRate", rate); row->setProperty("channels", channels); + row->setProperty("maxOutputError", unchanged); + add(row, original.finite && calibrated.finite && unchanged == 0); + } + { + Options options; options.embedded = true; + const auto original = render(options); options.mode = 1; + const auto unchanged = render(options); + const double difference = error(unchanged.audio, original.audio); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "embedded_processor_ignores_standalone_return_mode"); + row->setProperty("maxOutputError", difference); + add(row, original.prepared && unchanged.prepared && original.finite && unchanged.finite && difference == 0); + } + for (const std::array transition : {std::array{0,1},{10,11},{12,13},{15,16},{17,18},{21,22},{24,25},{27,28},{4,6}}) + { + Options options; options.type = transition[0]; options.nextType = transition[1]; options.spillover = true; + const auto original = render(options); options.mode = 1; + const auto calibrated = render(options); + const int begin = juce::roundToInt(options.rate * .22); + const double tailError = error(calibrated.audio, original.audio, + BuiltInReverbReturnLevels::linear[static_cast(options.type)], begin); + const double tailPeak = original.audio.getMagnitude(0, begin, original.audio.getNumSamples() - begin); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "retiring_type_keeps_its_own_gain"); + row->setProperty("outgoingType", transition[0]); row->setProperty("incomingType", transition[1]); + row->setProperty("maxTailGainError", tailError); row->setProperty("originalTailPeak", tailPeak); + add(row, original.finite && calibrated.finite && tailPeak > 1e-10 && tailError < 2e-6); + } + for (const int type : {6,15,19,20,24,33}) + { + Options options; options.type = type; options.mode = 1; options.amplitude = 3; + const auto capture = render(options); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "high_input_finite_safety_bound"); + row->setProperty("type", type); row->setProperty("peak", capture.peak); + add(row, capture.prepared && capture.finite && capture.peak <= 4.0); + } + for (const int type : {2,4,5,6,19,20,24,38}) + for (const bool held : {false,true}) + { + Options options; options.type = type; options.mode = 1; options.amplitude = .25f; + options.maximumDecay = true; options.held = held; + const auto capture = render(options); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "maximum_decay_and_infinite_hold_finite_bound"); + row->setProperty("type", type); row->setProperty("holdAcceptsInput", held); row->setProperty("seconds", .40); + row->setProperty("peak", capture.peak); + row->setProperty("long_duration_hold_stability", "not_asserted"); + add(row, capture.prepared && capture.finite && capture.peak <= 4.0); + } + + bool markerPass = fixturePass; + juce::ValueTree defaultTree("IR"), customTree("IR"); + BuiltInConvolution defaultIR, customIR; + markerPass = defaultIR.selectDefault() && customIR.loadFile(customFile) && markerPass; + defaultIR.save(defaultTree); customIR.save(customTree); + markerPass = static_cast(defaultTree["irGeneratedDefault"]) + && !static_cast(customTree["irGeneratedDefault"]) && markerPass; + customTree.setProperty("irName", "Studio room (generated)", nullptr); + BuiltInConvolution collision; + markerPass = collision.restore(customTree) && markerPass; + juce::ValueTree collisionTree("IR"); collision.save(collisionTree); + markerPass = !static_cast(collisionTree["irGeneratedDefault"]) && markerPass; + defaultTree.removeProperty("irGeneratedDefault", nullptr); + BuiltInConvolution migratedDefault; + markerPass = migratedDefault.restore(defaultTree) && markerPass; + juce::ValueTree migratedTree("IR"); migratedDefault.save(migratedTree); + markerPass = static_cast(migratedTree["irGeneratedDefault"]) && markerPass; + customTree.setProperty("irName", "custom matrix.wav", nullptr); customTree.removeProperty("irGeneratedDefault", nullptr); + BuiltInConvolution migratedCustom; + markerPass = migratedCustom.restore(customTree) && markerPass; + juce::ValueTree migratedCustomTree("IR"); migratedCustom.save(migratedCustomTree); + markerPass = !static_cast(migratedCustomTree["irGeneratedDefault"]) && markerPass; + auto* markerRow = new juce::DynamicObject(); markerRow->setProperty("kind", "generated_and_custom_ir_marker_migration"); + add(markerRow, markerPass); + { + Options options; options.type = 7; options.irState = &defaultTree; + const auto original = render(options); options.mode = 1; + const auto calibrated = render(options); + const double difference = error(calibrated.audio, original.audio, BuiltInReverbReturnLevels::linear[7]); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "old_generated_ir_marker_uses_generated_gain"); + row->setProperty("maxGainError", difference); + add(row, original.prepared && calibrated.prepared && original.finite && calibrated.finite && difference < 2e-6); + } + for (const double rate : {44100.0,48000.0,96000.0,192000.0}) + for (const int channels : {1,2}) + { + Options matrix; matrix.type = 7; matrix.customIR = true; matrix.irState = &collisionTree; + matrix.rate = rate; matrix.channels = channels; + const auto originalMatrix = render(matrix); matrix.mode = 1; + const auto calibratedMatrix = render(matrix); + const double matrixError = error(calibratedMatrix.audio, originalMatrix.audio); + bool allFourNonzero = originalMatrix.audio.getNumChannels() == channels + 2; + for (int channel = 0; channel < originalMatrix.audio.getNumChannels(); ++channel) + allFourNonzero = originalMatrix.audio.getMagnitude(channel, 0, originalMatrix.audio.getNumSamples()) > 1e-10 && allFourNonzero; + matrix.wet = 0; matrix.dry = .7f; + const auto calibratedMatrixDry = render(matrix); matrix.mode = 0; + const auto originalMatrixDry = render(matrix); + const double matrixDryError = error(calibratedMatrixDry.audio, originalMatrixDry.audio); + auto* matrixRow = new juce::DynamicObject(); matrixRow->setProperty("kind", "custom_matrix_ir_main_and_cross_unity_and_dry"); + matrixRow->setProperty("sampleRate", rate); matrixRow->setProperty("mainChannels", channels); + matrixRow->setProperty("maxOutputError", matrixError); matrixRow->setProperty("maxDryError", matrixDryError); + matrixRow->setProperty("fourOutputsNonzero", allFourNonzero); + add(matrixRow, originalMatrix.prepared && calibratedMatrix.prepared && originalMatrix.finite + && calibratedMatrix.finite && allFourNonzero && matrixError == 0 && matrixDryError == 0); + } + auto* heapRow = new juce::DynamicObject(); heapRow->setProperty("kind", "callback_heap_probe"); + heapRow->setProperty("measured", heapMeasured); heapRow->setProperty("allocations", allocations); heapRow->setProperty("frees", frees); + add(heapRow, allocations == 0 && frees == 0); + result->setProperty("pass", overall); result->setProperty("cases", cases); result->setProperty("caseCount", cases.size()); + result->setProperty("maxGainError", maximumGainError); result->setProperty("maxDryError", maximumDryError); + result->setProperty("maxPartitionError", maximumPartitionError); result->setProperty("callbackHeapMeasured", heapMeasured); + result->setProperty("callbackAllocations", allocations); result->setProperty("callbackFrees", frees); + result->setProperty("scope", "Routing, fixed gains, preserved dry/legacy/custom IR, state, bounded output and fade partitioning. No audibility or dominance claim; Release heap probe is not asserted."); + return result; +} diff --git a/Source/FreePluginReverbSharedGuitarProject.h b/Source/FreePluginReverbSharedGuitarProject.h new file mode 100644 index 0000000..ed17827 --- /dev/null +++ b/Source/FreePluginReverbSharedGuitarProject.h @@ -0,0 +1,207 @@ +#pragma once + +// Opt-in local QA: the user's saved single-track guitar project is read only. +// Cache the complete native NAM track once, then compose each fresh Reverb with +// that identical signal. No source/output normalization or guitar-onset crop. +inline juce::var renderReverbSharedGuitarProject() +{ + constexpr int rate = 44100, frames = 512; + constexpr double tailSeconds = 10; + const auto root = juce::File::getCurrentWorkingDirectory(); + const auto projectFile = root.getChildFile("test projects/test guitar 1.osproj"); + const auto qaPath=juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_REVERB_QA_OUTPUT", "output/reverb-shared-guitar-project"); + const auto directory = root.getChildFile(qaPath).getChildFile("native"); + const bool profileOutput=juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_REVERB_QA_PROFILE_OUTPUT", "0")=="1"; + const auto filter=juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_REVERB_QA_TYPE", "-1").getIntValue(); + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Shared guitar project: saved native NAM track then standalone canonical Reverb"); + result->setProperty("directory", directory.getFullPathName()); + result->setProperty("projectPath", projectFile.getFullPathName()); + result->setProperty("sampleRate", rate); result->setProperty("blockSize", frames); + result->setProperty("audibility", "not_asserted"); result->setProperty("audioQuality", "not_asserted"); + result->setProperty("rmsBalance", "diagnostic_only"); result->setProperty("dominance", "not_asserted"); + const auto fail = [result] (const juce::String& reason) { result->setProperty("pass", false); result->setProperty("failure", reason); return juce::var(result); }; + const auto project = juce::JSON::parse(projectFile.loadFileAsString()); + const auto tracksValue = project["tracks"]; + const auto* tracks = tracksValue.getArray(); + if (!projectFile.existsAsFile() || !tracks || tracks->size() != 1 || !directory.createDirectory().wasOk()) + return fail("Expected existing one-track project and writable isolated output directory"); + const auto trackState = tracks->getReference(0); + const auto clipsValue = trackState["clips"], fxValue = trackState["trackFXPaths"], statesValue = trackState["trackFXStates"], assetsValue = project["namAssets"]; + const auto* clips = clipsValue.getArray(); + const auto* fx = fxValue.getArray(); + const auto* states = statesValue.getArray(); + const auto* assets = assetsValue.getArray(); + if (!clips || clips->size() != 1 || !fx || fx->size() != 1 || fx->getReference(0).toString() != "OpenStudio NAM Rack" + || !states || states->size() != 1 || !assets || assets->size() != 1) + return fail("Expected one saved NAM Rack, one clip and one referenced NAM model"); + const auto emptyArray = [] (const juce::var& value) { const auto* array = value.getArray(); return !array || array->isEmpty(); }; + const bool routing = static_cast(trackState["volumeDB"]) == 0 && static_cast(trackState["pan"]) == 0 + && static_cast(trackState["stereoWidth"]) == 100 && static_cast(trackState["playbackOffsetMs"]) == 0 + && static_cast(trackState["masterSendEnabled"]) && !static_cast(trackState["phaseInverted"]) + && !static_cast(trackState["muted"]) && !static_cast(trackState["monitorEnabled"]) + && emptyArray(trackState["sends"]) && emptyArray(trackState["inputFXPaths"]) + && emptyArray(trackState["automationLanes"]) && emptyArray(project["masterFXPaths"]) + && emptyArray(project["masterAutomationLanes"]) && static_cast(project["masterVolume"]) == 1 + && static_cast(project["masterPan"]) == 0 && !static_cast(project["masterMono"]) + && !static_cast(project["isMasterMuted"]) && project["processingPrecision"].toString() == "float32"; + if (!routing) return fail("Project routing changed: this QA renderer requires the audited single unity audio track without automation or other FX"); + const auto clip = clips->getReference(0); + const auto path = [] (juce::String text) { return text.replace("\\\\", "\\"); }; + const juce::File sourceFile(path(clip["filePath"].toString())); + const auto modelAsset = assets->getReference(0); + const juce::File modelFile(path(modelAsset["path"].toString())); + juce::MemoryBlock namState; + if (!sourceFile.existsAsFile() || !modelFile.existsAsFile() || !namState.fromBase64Encoding(states->getReference(0).toString())) + return fail("Missing recorded source/model or invalid saved JUCE NAM state"); + const auto namTree = juce::ValueTree::readFromData(namState.getData(), namState.getSize()); + const auto modelHash = juce::SHA256(modelFile).toHexString(); + if (!namTree.hasType("OpenStudioNAMRack") || modelHash != modelAsset["checksum"].toString() + || static_cast(namTree.getProperty("reverbEnabled")) != 0) + return fail("Saved NAM type/model fingerprint or disabled internal reverb verification failed"); + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::unique_ptr reader(formats.createReaderFor(sourceFile)); + if (!reader || reader->sampleRate != rate || reader->numChannels != 1 || reader->lengthInSamples <= 0) + return fail("Expected existing mono 44.1k guitar recording"); + const double projectEnd = static_cast(project["projectRange"]["end"]); + const double renderSeconds = projectEnd + tailSeconds; + if (!(projectEnd > 0 && projectEnd < 120)) return fail("Unexpected project range"); + const int length = static_cast(std::llround(renderSeconds * rate)); + const double sourceStart = static_cast(clip["startTime"]); + const auto trackId = trackState["id"].toString(); + auto rack = std::make_unique(); rack->setNonRealtime(true); + const bool restored = rack->restoreProjectStateInformation(namState.getData(), static_cast(namState.getSize())); + auto* rackPtr = rack.get(); + TrackProcessor nativeTrack; nativeTrack.setNonRealtime(true); nativeTrack.setTrackType(TrackType::Audio); + nativeTrack.setTrackChannelCount(2); nativeTrack.setProcessingPrecisionMode(ProcessingPrecisionMode::Float32); + nativeTrack.setInputChannels(static_cast(trackState["inputStartChannel"]), static_cast(trackState["inputChannelCount"])); + nativeTrack.setVolume(0); nativeTrack.setPan(0); nativeTrack.setPanLaw(PanLaw::Linear); nativeTrack.setStereoWidth(100); + nativeTrack.setMute(false); nativeTrack.setInputMonitoring(false); nativeTrack.setRecordArmed(false); + nativeTrack.setChannelStripEQEnabled(false); nativeTrack.setDCOffsetRemoval(false); nativeTrack.setPDCDelay(0); + const bool mounted = nativeTrack.addTrackFX(std::move(rack), rate, frames); + nativeTrack.prepareToPlay(rate, frames); nativeTrack.resetOfflineRenderState(); + const bool ready = restored && mounted && rackPtr->hasAmpModel() && rackPtr->ampModelIncludesCab() + && juce::File(rackPtr->getAmpModelPath()) == modelFile && rackPtr->getLastLoadError().isEmpty(); + if (!ready) return fail("Native saved NAM resource readiness failed: " + rackPtr->getLastLoadError()); + const int latency = juce::jmax(0, nativeTrack.getChainLatency()); + const int processedLength = length + latency; + PlaybackEngine playback; playback.setRenderMode(true); + playback.addClip(sourceFile, sourceStart, static_cast(clip["duration"]), trackId, + static_cast(clip["offset"]), static_cast(clip["volumeDB"]), + static_cast(clip["fadeIn"]), static_cast(clip["fadeOut"]), clip["id"].toString()); + juce::AudioBuffer block(2, frames), cachedUpstream(2, processedLength), baseline(2, length), sourceTimeline(2, length); + juce::MidiBuffer midi; bool passed = true; + for (int start = 0; start < processedLength; start += frames) + { + const int count = juce::jmin(frames, processedLength - start); + block.setSize(2, count, false, false, true); block.clear(); midi.clear(); + playback.fillTrackBuffer(trackId, block, static_cast(start) / rate, count, rate); + if (start < length) + for (int channel = 0; channel < 2; ++channel) + sourceTimeline.copyFrom(channel, start, block, channel, 0, juce::jmin(count, length - start)); + nativeTrack.setCurrentBlockPosition(static_cast(start) / rate); + passed = nativeTrack.tryProcessBlock(block, midi) && passed; + for (int channel = 0; channel < 2; ++channel) cachedUpstream.copyFrom(channel, start, block, channel, 0, count); + } + for (int channel = 0; channel < 2; ++channel) baseline.copyFrom(channel, 0, cachedUpstream, channel, latency, length); + nativeTrack.releaseResources(); + passed = writeProbeWave(directory.getChildFile("baseline-nam-only.wav"), baseline, rate) && passed; + passed = writeProbeWave(directory.getChildFile("source-timeline.wav"), sourceTimeline, rate) && passed; + passed = directory.getChildFile("saved-nam-project.state").replaceWithData(namState.getData(), namState.getSize()) && passed; + result->setProperty("baselineFile", "baseline-nam-only.wav"); result->setProperty("sourceTimelineFile", "source-timeline.wav"); + result->setProperty("sourcePath", sourceFile.getFullPathName()); result->setProperty("sourceStartSeconds", sourceStart); + result->setProperty("sourceDurationSeconds", static_cast(clip["duration"])); result->setProperty("sourceOffsetSeconds", static_cast(clip["offset"])); + result->setProperty("sourceTimelinePeak", sourceTimeline.getMagnitude(0, length)); result->setProperty("baselinePeak", baseline.getMagnitude(0, length)); + result->setProperty("renderSeconds", renderSeconds); result->setProperty("projectEndSeconds", projectEnd); result->setProperty("tailSeconds", tailSeconds); + result->setProperty("upstreamLatencySamples", latency); result->setProperty("cachedUpstream", true); result->setProperty("sourceGain", 1); + result->setProperty("dry", .7); result->setProperty("outputDb", profileOutput?juce::var("authored_per_voice"):juce::var(0)); result->setProperty("returnLevel", "Calibrated"); + auto* metadata = new juce::DynamicObject(); + metadata->setProperty("projectSha256", juce::SHA256(projectFile).toHexString()); metadata->setProperty("sourceSha256", juce::SHA256(sourceFile).toHexString()); + metadata->setProperty("savedNamStateSha256", juce::SHA256(namState).toHexString()); metadata->setProperty("modelPath", modelFile.getFullPathName()); + metadata->setProperty("modelSha256", modelHash); metadata->setProperty("modelCaptureType", rackPtr->getAmpCaptureType()); + metadata->setProperty("namReady", ready); metadata->setProperty("namInternalReverbEnabled", 0); + metadata->setProperty("metronomeIncluded", false); metadata->setProperty("tempo", project["tempo"]); metadata->setProperty("timeSignature", project["timeSignature"]); + metadata->setProperty("pipeline", "Actual PlaybackEngine timeline scheduling and TrackProcessor saved complete NAM Rack, cached once before latency compensation; each fresh native standalone Reverb consumes identical uncompensated cache, then the same reported upstream latency is trimmed from final output"); + metadata->setProperty("routingScope", "Audited single audio track, unity linear pan/fader/master, no monitoring/automation/sends/other FX; full AudioEngine graph and device playback not asserted"); + metadata->setProperty("normalization", "None: source input gain1 and native float32 mixed output; source silence and clip position preserved"); + metadata->setProperty("tailCompleteness", "not_asserted; fixed10 second listening tail after saved project range may truncate long reverbs"); + metadata->setProperty("assetUse", "Existing user project/audio and referenced local NAM model read only; reference metadata is not embedded model data; model licence unspecified in project"); + result->setProperty("metadata", juce::var(metadata)); + const std::array labels {"dry", "wet25", "wet50", "wet75", "wet100"}; + const std::array wetLevels {0, .25f, .5f, .75f, 1}; + const std::array originalNames {"Room", "Hall", "Plate", "Chamber", "Spring", "Shimmer", "Nonlinear", "Convolution", "Church", "Ambience", "Vintage hall", "Vintage random", "Contour room", "Open hall", "Diffuse loop", "Magnetic heads", "Positioned room", "Rising space", "Bloom field", "Cloud field", "Vowel hall", "Drifting hall", "Drifting chamber", "Drifting diffuse", "Clear plate", "Clear room", "Clear random"}; + juce::Array rows; double maximumScalingError = 0; juce::int64 allBoundSamples = 0; + for (int type = 0; type < OpenStudioReverb::standaloneTypeCount; ++type) + { + if(filter>=0&&filter!=type)continue; + const auto& voice = OpenStudioReverbFactoryPresets::profiles[static_cast(type)]; + juce::MemoryBlock authoredState; const bool built = OpenStudioReverbFactoryPresets::buildState(voice.name, authoredState); + const auto stateFile = juce::String("canonical-type-") + juce::String(type).paddedLeft('0', 2) + ".state"; + bool written = built && directory.getChildFile(stateFile).replaceWithData(authoredState.getData(), authoredState.getSize()); + std::array, 5> outputs; + for (size_t variant = 0; variant < outputs.size(); ++variant) + { + auto processor = std::make_unique(true); processor->setNonRealtime(true); + processor->setStateInformation(authoredState.getData(), static_cast(authoredState.getSize())); + processor->dryLevel.store(.7f); processor->wetLevel.store(wetLevels[variant]); processor->sendMode.store(0); + if (!profileOutput) + processor->outputTrimDb.store(0); + processor->reverbReturnMode.store(1); + processor->prepareToPlay(rate, frames); + const int channels = juce::jmax(2, processor->getTotalNumOutputChannels()); + juce::AudioBuffer reverbBlock(channels, frames); outputs[variant].setSize(2, length); + for (int start = 0; start < processedLength; start += frames) + { + const int count = juce::jmin(frames, processedLength - start); + reverbBlock.setSize(channels, count, false, false, true); reverbBlock.clear(); midi.clear(); + for (int channel = 0; channel < 2; ++channel) reverbBlock.copyFrom(channel, 0, cachedUpstream, channel, start, count); + processor->processBlock(reverbBlock, midi); + const int first = juce::jmax(start, latency), end = juce::jmin(start + count, length + latency); + if (end > first) for (int channel = 0; channel < 2; ++channel) + outputs[variant].copyFrom(channel, first - latency, reverbBlock, channel, first - start, end - first); + } + processor->releaseResources(); + } + auto* files = new juce::DynamicObject(); auto* peaks = new juce::DynamicObject(); + auto* unityCounts = new juce::DynamicObject(); auto* boundCounts = new juce::DynamicObject(); + bool finite = true; double scalingError = 0, dryEnergy = 0, wetEnergy = 0; + for (size_t variant = 0; variant < outputs.size(); ++variant) + { + const auto filename = juce::String(type).paddedLeft('0', 2) + "-" + labels[variant] + ".wav"; + files->setProperty(labels[variant], filename); written = writeProbeWave(directory.getChildFile(filename), outputs[variant], rate) && written; + double peak = 0; juce::int64 unity = 0, bound = 0; + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < length; ++sample) + { + const double value = outputs[variant].getSample(channel, sample); finite = std::isfinite(value) && finite; + peak = juce::jmax(peak, std::abs(value)); if (std::abs(value) > 1) ++unity; if (std::abs(value) >= 4) ++bound; + } + peaks->setProperty(labels[variant], peak); unityCounts->setProperty(labels[variant], unity); boundCounts->setProperty(labels[variant], bound); + allBoundSamples += bound; + } + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < length; ++sample) + { + const double dry = outputs[0].getSample(channel, sample), full = outputs[4].getSample(channel, sample) - dry; + dryEnergy += dry * dry; wetEnergy += full * full; + for (size_t variant = 1; variant < 4; ++variant) + scalingError = juce::jmax(scalingError, std::abs(outputs[variant].getSample(channel, sample) - dry - wetLevels[variant] * full)); + } + // Fixed scaling is only asserted when no final safety bound is active; + // clipping and absolute level remain reported diagnostics. + bool bounded = false; for (const auto* label : labels) bounded = static_cast(boundCounts->getProperty(label)) > 0 || bounded; + const bool rowPass = built && written && finite && dryEnergy > 0 && wetEnergy > 0 && (bounded || scalingError < 5e-5); + auto* row = new juce::DynamicObject(); row->setProperty("type", type); + row->setProperty("typeName", type < 27 ? juce::String(originalNames[static_cast(type)]) : juce::String(BuiltInRetroReverb::names[static_cast(type - 27)])); + row->setProperty("outputTrimDb",profileOutput?voice.outputTrimDb:0); row->setProperty("presetName", voice.name); row->setProperty("stateFile", stateFile); row->setProperty("files", juce::var(files)); row->setProperty("peaks", juce::var(peaks)); + row->setProperty("samplesAboveUnityDiagnostic", juce::var(unityCounts)); row->setProperty("samplesAtSafetyBoundDiagnostic", juce::var(boundCounts)); + row->setProperty("wetScalingMaxError", scalingError); row->setProperty("wetScalingAssertion", bounded ? "not_asserted_when_safety_bound_active" : "pass_or_fail"); + row->setProperty("dryRmsFullWindow", std::sqrt(dryEnergy / (2 * length))); row->setProperty("wet100ResidualRmsFullWindowDiagnostic", std::sqrt(wetEnergy / (2 * length))); + row->setProperty("finite", finite); row->setProperty("filesWritten", written); row->setProperty("pass", rowPass); + maximumScalingError = juce::jmax(maximumScalingError, scalingError); passed = rowPass && passed; rows.add(juce::var(row)); + auto* progress = new juce::DynamicObject(); progress->setProperty("completed", rows.size()); progress->setProperty("total", 41); + progress->setProperty("type", type); progress->setProperty("pass", rowPass); progress->setProperty("utc", juce::Time::getCurrentTime().toISO8601(true)); + directory.getParentDirectory().getChildFile("progress.json").replaceWithText(juce::JSON::toString(juce::var(progress), true)); + } + result->setProperty("cases", rows); result->setProperty("caseCount", rows.size()); result->setProperty("mixedFileCount", rows.size() * 5); + result->setProperty("maximumWetScalingError", maximumScalingError); result->setProperty("samplesAtSafetyBoundDiagnostic", allBoundSamples); + result->setProperty("pass", passed); return juce::var(result); +} diff --git a/Source/FreePluginReverbSparseDiffusionRegression.h b/Source/FreePluginReverbSparseDiffusionRegression.h new file mode 100644 index 0000000..7f8838e --- /dev/null +++ b/Source/FreePluginReverbSparseDiffusionRegression.h @@ -0,0 +1,224 @@ +#pragma once +#include "BuiltInReverbSparseDiffusion.h" +#include +#if JUCE_WINDOWS && defined(_DEBUG) +#include +namespace ReverbSparseHeapProbe +{ +inline thread_local bool active = false; +inline thread_local int allocations = 0, frees = 0; +inline _CRT_ALLOC_HOOK previous = nullptr; +inline int hook(int kind, void* data, size_t size, int block, long request, const unsigned char* file, int line) +{ + if (active) + { + if (kind == _HOOK_ALLOC || kind == _HOOK_REALLOC) ++allocations; + if (kind == _HOOK_FREE) ++frees; + } + return previous ? previous(kind, data, size, block, request, file, line) : 1; +} +} +#endif + +namespace ReverbSparseRegression +{ +struct Capture +{ + juce::AudioBuffer audio; + juce::uint64 sparseFrames = 0; + bool finite = true; + int allocations = 0, frees = 0; +}; + +template +Capture render(double rate, int mode, int blockSize, bool refined, bool toggle = false, + bool retire = false, bool spillover = false, bool held = false) +{ + auto family = std::make_unique(); + family->prepare(rate, mode); + typename Family::Settings settings; + settings.refined = refined; + settings.decay = 2; + const int length = juce::roundToInt(rate * .6); + const int feedEnd = juce::roundToInt(rate * .08); + const int holdAt = juce::roundToInt(rate * .10); + const int toggleAt = juce::roundToInt(rate * .12); + const int offAt = juce::roundToInt(rate * .22); + const int toggleBackAt = juce::roundToInt(rate * .24); + const int thirdToggleAt = juce::roundToInt(rate * .36); + const int reselectAt = juce::roundToInt(rate * .38); + Capture result { juce::AudioBuffer(2, length) }; + juce::uint32 random = 371; +#if JUCE_WINDOWS && defined(_DEBUG) + ReverbSparseHeapProbe::allocations = ReverbSparseHeapProbe::frees = 0; + ReverbSparseHeapProbe::previous = _CrtSetAllocHook(ReverbSparseHeapProbe::hook); + ReverbSparseHeapProbe::active = true; +#endif + for (int start = 0; start < length;) + { + int count = juce::jmin(blockSize, length - start); + for (const int event : {feedEnd, holdAt, toggleAt, offAt, toggleBackAt, thirdToggleAt, reselectAt}) + if (start < event) count = juce::jmin(count, event - start); + const bool active = !retire || start < offAt || start >= reselectAt; + settings.refined = toggle ? start >= toggleAt && (start < toggleBackAt || start >= thirdToggleAt) : refined; + if constexpr (std::is_same_v) settings.freeze = held && start >= holdAt; + else settings.hold = held && start >= holdAt ? 2.0f : 0.0f; + family->configure(active ? mode : -1, settings, spillover); + for (int sample = start; sample < start + count; ++sample) + { + random = random * 1664525u + 1013904223u; + const float noise = (static_cast(random >> 8) / 8388608.0f - 1) * .1f; + // Keep nonzero input during deselection to detect leaked history; + // reselection receives silence and must stay silent without Spillover. + const bool feed = sample < feedEnd || (retire && sample >= offAt && sample < reselectAt); + const auto output = family->process(feed ? noise : 0, feed ? -noise * .7f : 0); + for (int channel = 0; channel < 2; ++channel) + { + const float value = output[static_cast(channel)]; + result.finite = result.finite && std::isfinite(value); + result.audio.setSample(channel, sample, value); + } + } + start += count; + } +#if JUCE_WINDOWS && defined(_DEBUG) + ReverbSparseHeapProbe::active = false; + _CrtSetAllocHook(ReverbSparseHeapProbe::previous); + result.allocations = ReverbSparseHeapProbe::allocations; + result.frees = ReverbSparseHeapProbe::frees; +#endif + result.sparseFrames = family->sparseProcessedFrames(); + return result; +} + +inline double difference(const juce::AudioBuffer& first, const juce::AudioBuffer& second) +{ + double error = 0; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < first.getNumSamples(); ++sample) + error = juce::jmax(error, std::abs(static_cast(first.getSample(channel, sample)) - second.getSample(channel, sample))); + return error; +} + +inline double energy(const juce::AudioBuffer& audio, int start, int end) +{ + double value = 0; + for (int channel = 0; channel < 2; ++channel) + for (int sample = start; sample < end; ++sample) + { + const double amplitude = audio.getSample(channel, sample); + value += amplitude * amplitude; + } + return value; +} + +template +bool checkFamily(juce::Array& cases, const char* name, int mode, double rate, bool expectedRefinement) +{ + const auto original = render(rate, mode, 127, false); + const auto originalPartition = render(rate, mode, 513, false); + const auto refined = render(rate, mode, 127, true); + const auto refinedPartition = render(rate, mode, 513, true); + const auto live = render(rate, mode, 127, false, true); + const auto livePartition = render(rate, mode, 513, false, true); + const auto retired = render(rate, mode, 127, true, false, true); + const auto retained = render(rate, mode, 127, true, false, true, true); + const auto held = render(rate, mode, 127, true, false, false, false, true); + const double partitionError = juce::jmax(difference(original.audio, originalPartition.audio), + difference(refined.audio, refinedPartition.audio), difference(live.audio, livePartition.audio)); + const double changed = difference(original.audio, refined.audio); + const int reselectAt = juce::roundToInt(rate * .38); + const double staleEnergy = energy(retired.audio, reselectAt, retired.audio.getNumSamples()); + const double retainedEnergy = energy(retained.audio, reselectAt, retained.audio.getNumSamples()); + bool finite = true, noHeap = true; + for (const auto* capture : {&original, &originalPartition, &refined, &refinedPartition, &live, &livePartition, &retired, &retained, &held}) + { + finite = capture->finite && finite; + noHeap = capture->allocations == 0 && capture->frees == 0 && noHeap; + } + const bool pass = finite && noHeap && partitionError == 0 && original.sparseFrames == 0 + && (expectedRefinement ? changed > 0 && refined.sparseFrames > 0 : changed == 0 && refined.sparseFrames == 0) + && staleEnergy == 0 && retainedEnergy > 0 + && energy(held.audio, juce::roundToInt(rate * .3), held.audio.getNumSamples()) > 0; + auto* row = new juce::DynamicObject(); + row->setProperty("family", name); row->setProperty("mode", mode); row->setProperty("sampleRate", rate); + row->setProperty("pass", pass); row->setProperty("finite", finite); row->setProperty("partitionError", partitionError); + row->setProperty("originalSparseFrames", static_cast(original.sparseFrames)); + row->setProperty("refinedSparseFrames", static_cast(refined.sparseFrames)); + row->setProperty("responseDifferenceDiagnostic", changed); row->setProperty("staleReselectionEnergy", staleEnergy); + row->setProperty("retainedEnergyDiagnostic", retainedEnergy); row->setProperty("heapFree", noHeap); + row->setProperty("originalEnergyBefore80msDiagnostic", energy(original.audio, 0, juce::roundToInt(rate * .08))); + row->setProperty("refinedEnergyBefore80msDiagnostic", energy(refined.audio, 0, juce::roundToInt(rate * .08))); + cases.add(row); + return pass; +} + +inline bool checkUtility(juce::Array& cases, double rate) +{ + BuiltInReverbSparseDiffusion diffusion; + diffusion.prepare(rate); diffusion.configure(false); + bool bypass = true, finite = true, bounded = true, resetSilent = true; + for (int sample = 0; sample < 4096; ++sample) + { + const BuiltInReverbSparseDiffusion::Pair input {sample % 3 ? .371f : -.917f, sample % 7 ? -.537f : .819f}; + bypass = diffusion.process(input) == input && bypass; + } + bypass = diffusion.processedFrames() == 0 && bypass; + diffusion.reset(); diffusion.configure(true); + std::array impulseEnergy {}; + int lastNonzero = 0; + for (int sample = 0; sample <= diffusion.supportSamples() + 2; ++sample) + { + const auto output = diffusion.process(sample == 0 ? BuiltInReverbSparseDiffusion::Pair {1,1} : BuiltInReverbSparseDiffusion::Pair {}); + for (size_t channel = 0; channel < 2; ++channel) + { + impulseEnergy[channel] += static_cast(output[channel]) * output[channel]; + if (output[channel] != 0) lastNonzero = sample; + } + } + const bool unitEnergy = std::abs(impulseEnergy[0] - 1) < 1e-6 && std::abs(impulseEnergy[1] - 1) < 1e-6; + diffusion.reset(); diffusion.configure(true); + for (int sample = 0; sample < 4096; ++sample) + { + const auto output = diffusion.process({sample % 2 ? 1.0f : -1.0f, sample % 3 ? -1.0f : 1.0f}); + for (const float value : output) { finite = finite && std::isfinite(value); bounded = bounded && std::abs(value) <= 2.829f; } + } + diffusion.configure(false); + for (int sample = 0; sample < static_cast(rate * .05) + 1; ++sample) diffusion.process({}); + diffusion.configure(true); + for (int sample = 0; sample <= diffusion.supportSamples(); ++sample) resetSilent = diffusion.process({}) == BuiltInReverbSparseDiffusion::Pair {} && resetSilent; + diffusion.reset(); diffusion.configure(true); + for (int sample = 0; sample <= diffusion.supportSamples(); ++sample) resetSilent = diffusion.process({}) == BuiltInReverbSparseDiffusion::Pair {} && resetSilent; + auto* row = new juce::DynamicObject(); + row->setProperty("family", "Sparse FIR"); row->setProperty("sampleRate", rate); row->setProperty("exactOriginalBypass", bypass); + row->setProperty("unitImpulseEnergy", unitEnergy); row->setProperty("supportSamples", diffusion.supportSamples()); + row->setProperty("lastImpulseSample", lastNonzero); row->setProperty("peakAmplificationBound", 2.829); + row->setProperty("resetAndDisableDiscardHistory", resetSilent); row->setProperty("finite", finite); + const bool pass = bypass && unitEnergy && bounded && resetSilent && finite && lastNonzero == diffusion.supportSamples(); + row->setProperty("pass", pass); cases.add(row); return pass; +} +} + +inline juce::var checkReverbSparseDiffusion() +{ + juce::Array cases; + bool pass = true; + for (const double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + { + pass = ReverbSparseRegression::checkUtility(cases, rate) && pass; + for (int mode = 0; mode < 3; ++mode) + pass = ReverbSparseRegression::checkFamily(cases, "Plate", mode, rate, true) && pass; + for (int mode = 0; mode < 4; ++mode) + pass = ReverbSparseRegression::checkFamily(cases, "Ambient", mode, rate, mode == 2) && pass; + } + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Opt-in sparse reverb readout diffusion"); result->setProperty("pass", pass); result->setProperty("cases", cases); + result->setProperty("onsetAndEnergyMetrics", "diagnostic_only"); result->setProperty("audioQuality", "not_asserted"); + result->setProperty("loudnessMatch", "not_asserted"); +#if JUCE_WINDOWS && defined(_DEBUG) + result->setProperty("callbackHeapMeasured", true); +#else + result->setProperty("callbackHeapMeasured", false); +#endif + return result; +} diff --git a/Source/FreePluginReverbVoiceOptionsRegression.h b/Source/FreePluginReverbVoiceOptionsRegression.h new file mode 100644 index 0000000..05a9922 --- /dev/null +++ b/Source/FreePluginReverbVoiceOptionsRegression.h @@ -0,0 +1,82 @@ +#pragma once + +// Deterministic readout/state checks. Neither these identities nor their RMS +// diagnostics assert that a listener prefers the additional broadband bed. +inline juce::var checkReverbVoiceOptions() +{ + using namespace ReverbReturnRegression; + juce::Array rows; + bool overall=true, heapMeasured=false; + int allocations=0,frees=0; + const auto add=[&](const char* kind,bool pass,double maximum=0){auto* row=new juce::DynamicObject();row->setProperty("kind",kind);row->setProperty("pass",pass);row->setProperty("maxError",maximum);rows.add(juce::var(row));overall=pass&&overall;}; + auto original=std::make_unique(true); + auto restored=std::make_unique(true); + auto embedded=std::make_unique(false); + bool state=original->ambBody.load()==0&&original->shimmerScheduling.load()==0; + state=setFreePluginParamForRegression(*original,"ambBody",.5f)&&setFreePluginParamForRegression(*original,"shimmerScheduling",1)&&state; + juce::MemoryBlock saved,repeated;original->getStateInformation(saved);restored->setStateInformation(saved.getData(),static_cast(saved.getSize()));restored->getStateInformation(repeated); + state=saved==repeated&&restored->ambBody.load()==.5f&&restored->shimmerScheduling.load()==1&&state; + const auto parameters=describeFreePluginForRegression(*original)["parameters"]; + state=parameters.size()==1090&¶meters[1087]["id"].toString()=="shimmerScheduling"&¶meters[1088]["id"].toString()=="ambBody"&&state; + for(const bool invalid:{false,true}) + { + auto tree=juce::ValueTree::readFromData(saved.getData(),saved.getSize()); + for(const char* id:{"ambBody","shimmerScheduling"})if(invalid)tree.setProperty(id,std::numeric_limits::quiet_NaN(),nullptr);else tree.removeProperty(id,nullptr); + const auto old=bytes(tree);restored->setStateInformation(old.getData(),static_cast(old.getSize())); + state=restored->ambBody.load()==0&&restored->shimmerScheduling.load()==0&&state; + } + state=setFreePluginNormalizedForRegression(*restored,"ambBody",1)&&restored->ambBody.load()==1&&state; + state=!setFreePluginParamForRegression(*embedded,"ambBody",1)&&!setFreePluginParamForRegression(*embedded,"shimmerScheduling",1)&&state; + add("append_state_old_and_invalid_migration",state); + + const auto render=[&](int type,double rate,int channels,float bodyAmount,float wet,bool live,bool irregular) + { + auto processor=std::make_unique(true);auto layout=processor->getBusesLayout(); + const auto channelSet=channels==1?juce::AudioChannelSet::mono():juce::AudioChannelSet::stereo();layout.inputBuses.set(0,channelSet);layout.outputBuses.set(0,channelSet); + Capture capture;capture.prepared=processor->setBusesLayout(layout);processor->selectAlgorithm(type); + processor->ambBody.store(bodyAmount);processor->wetLevel.store(wet);processor->dryLevel.store(.7f);processor->prepareToPlay(rate,256); + const int samples=static_cast(rate*1.25);capture.audio.setSize(channels,samples);juce::AudioBuffer block(juce::jmax(2,channels),1021);juce::MidiBuffer midi; + const std::array partitions{1,17,63,257,1021};int start=0,index=0; + const std::array events{static_cast(rate*.3),static_cast(rate*.8)}; + while(startambBody.store(1); + if (live && start == events[1]) + processor->ambBody.store(0); + int count=juce::jmin(irregular?partitions[static_cast(index++%5)]:256,samples-start); + if(live)for(const int event:events)if(event>start)count=juce::jmin(count,event-start); + block.setSize(juce::jmax(2,channels),count,false,false,true);block.clear(); + for(int ch=0;ch(std::sin(juce::MathConstants::twoPi*145*t)+.4*std::sin(juce::MathConstants::twoPi*800*t))*(ch==0?1.0f:.8f):0;block.setSample(ch,sample,value);} +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations=ReverbCompatibilityHeapProbe::frees=0;ReverbCompatibilityHeapProbe::previous=_CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook);ReverbCompatibilityHeapProbe::active=true; +#endif + processor->processBlock(block,midi); +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active=false;_CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous);allocations+=ReverbCompatibilityHeapProbe::allocations;frees+=ReverbCompatibilityHeapProbe::frees;heapMeasured=true; +#endif + for(int ch=0;chreleaseResources();return capture; + }; + for(const double rate:{44100.0,48000.0,96000.0,192000.0})for(const int channels:{1,2}) + { + const auto zero=render(20,rate,channels,0,1,false,true),half=render(20,rate,channels,.5f,1,false,true),full=render(20,rate,channels,1,1,false,true); + double linearError=0; + for(int ch=0;ch1e-7,bodyInfluence); + const auto dry0=render(20,rate,channels,0,0,false,true),dry1=render(20,rate,channels,1,0,false,true); + const double dryError=error(dry0.audio,dry1.audio);add("vowel_dry_exact_parity",dryError==0,dryError); + const auto regular=render(20,rate,channels,0,1,true,false),irregular=render(20,rate,channels,0,1,true,true); + const double partition=error(regular.audio,irregular.audio);add("live_body_ramp_partition_parity",regular.finite&&irregular.finite&&partition<2e-7,partition); + } + for(int type=0;typesetProperty("plugin","Standalone append-only pitch scheduling and Vowel body controls");result->setProperty("cases",juce::var(rows));result->setProperty("callbackHeapMeasured",heapMeasured);result->setProperty("callbackAllocations",allocations);result->setProperty("callbackFrees",frees);result->setProperty("pass",overall&&allocations==0&&frees==0);result->setProperty("audioQuality","not_asserted");result->setProperty("perceivedDominance","not_asserted");return juce::var(result); +} diff --git a/Source/FreePluginReverbVoicedBalance.h b/Source/FreePluginReverbVoicedBalance.h new file mode 100644 index 0000000..80ddf55 --- /dev/null +++ b/Source/FreePluginReverbVoicedBalance.h @@ -0,0 +1,170 @@ +#pragma once + +// QA-only actual mixed renders of the authored canonical states. Original and +// Calibrated here refer to Return level; each voice retains its authored +// Processing, motion and character settings. Output audio is never normalized. +inline juce::var renderReverbVoicedBalancePack() +{ + constexpr int rate = 48000, frames = 128, length = rate * 10; + constexpr float commonInputGain = .5f; + const auto root = juce::File::getCurrentWorkingDirectory(); + const auto directory = root.getChildFile(juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_REVERB_QA_OUTPUT","output/reverb-voiced-balance")).getChildFile("native"); + const bool profileOutput=juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_REVERB_QA_PROFILE_OUTPUT","0")=="1"; + const auto fixtureDirectory = root.getChildFile("output/reverb-recorded/sources"); + bool passed = directory.createDirectory().wasOk(); + const std::array materials { + "percussion", "synthetic-vowel", "sustained-chord", + "recorded-guitar-low-notes", "recorded-guitar-high-notes", "recorded-guitar-layered-chord", "juce-singing-sample" + }; + const std::array labels {"dry", "original100", "calibrated25", "calibrated50", "calibrated75", "calibrated100"}; + std::array, 7> sources; + std::array excitationFrames {rate * 3, rate * 3, rate * 3, 0, 0, 0, 0}; + const auto generated = makeReverbListeningSources(); + for (auto& source : sources) { source.setSize(2, length); source.clear(); } + for (size_t material = 0; material < generated.size(); ++material) + for (int channel = 0; channel < 2; ++channel) + sources[material].copyFrom(channel, 0, generated[material], channel, 0, generated[material].getNumSamples()); + juce::AudioFormatManager formats; formats.registerBasicFormats(); + juce::Array inputRows; + for (size_t material = 3; material < sources.size(); ++material) + { + const auto file = fixtureDirectory.getChildFile(juce::String(materials[material]) + ".wav"); + std::unique_ptr reader(formats.createReaderFor(file)); + const bool valid = reader && reader->sampleRate == rate && reader->numChannels == 1 + && reader->lengthInSamples > 0 && reader->lengthInSamples < length; + passed = valid && passed; + if (valid) + { + excitationFrames[material] = static_cast(reader->lengthInSamples); + const bool read = reader->read(&sources[material], 0, excitationFrames[material], 0, true, false); + sources[material].copyFrom(1, 0, sources[material], 0, 0, excitationFrames[material]); + passed = read && passed; + } + } + for (size_t material = 0; material < sources.size(); ++material) + { + const double beforePeak = sources[material].getMagnitude(0, length); + sources[material].applyGain(commonInputGain); + const auto filename = juce::String("source-") + materials[material] + ".wav"; + passed = writeProbeWave(directory.getChildFile(filename), sources[material], rate) && passed; + auto* row = new juce::DynamicObject(); row->setProperty("material", materials[material]); + row->setProperty("sourceFile", filename); row->setProperty("commonInputGain", commonInputGain); + row->setProperty("peakBeforeCommonInputGain", beforePeak); row->setProperty("peakAfterCommonInputGain", sources[material].getMagnitude(0, length)); + row->setProperty("excitationSeconds", static_cast(excitationFrames[material]) / rate); + row->setProperty("provenance", material < 3 ? "Deterministic generated fixture; synthetic vowel is not human voice" + : material < 6 ? "Existing CC0 FreePats recorded electric-guitar DI notes, arranged by prepare_reverb_recorded_fixtures.py; mono duplicated to stereo input, not a stereo recording or continuous live take" + : "Existing short JUCE singing audio asset; mono downmix duplicated to stereo input; recording chain/performer/binary licence credits unverified, local diagnostic QA only; not broad vocal qualification"); + inputRows.add(juce::var(row)); + } + const auto sourceManifest = fixtureDirectory.getChildFile("manifest.json"); + const bool copiedManifest = sourceManifest.existsAsFile() + && sourceManifest.copyFileTo(directory.getChildFile("recorded-source-manifest.json")); + passed = copiedManifest && passed; + + const auto render = [&] (const juce::MemoryBlock& authoredState, size_t material, int variant) + { + auto processor = std::make_unique(true); + processor->setStateInformation(authoredState.getData(), static_cast(authoredState.getSize())); + processor->dryLevel.store(.7f); + processor->wetLevel.store(variant == 0 ? 0.0f : variant == 1 ? 1.0f : static_cast(variant-1)*.25f); + processor->sendMode.store(0); if(!profileOutput)processor->outputTrimDb.store(0); + processor->reverbReturnMode.store(variant >= 2 ? 1.0f : 0.0f); + processor->prepareToPlay(rate, frames); + juce::AudioBuffer block(juce::jmax(2, processor->getTotalNumOutputChannels()), frames), output(2, length); + juce::MidiBuffer midi; + for (int start = 0; start < rate / 10; start += frames) { block.clear(); processor->processBlock(block, midi); } + for (int start = 0; start < length; start += frames) + { + block.clear(); + for (int channel = 0; channel < 2; ++channel) block.copyFrom(channel, 0, sources[material], channel, start, frames); + processor->processBlock(block, midi); + for (int channel = 0; channel < 2; ++channel) output.copyFrom(channel, start, block, channel, 0, frames); + } + processor->releaseResources(); + return output; + }; + juce::Array rows; + double maximumHalfError = 0, maximumGainError = 0, maximumPeak = 0; + juce::int64 aboveUnity = 0, atSafetyBound = 0; + for (int type = 0; type < OpenStudioReverb::standaloneTypeCount; ++type) + { + const auto& voice = OpenStudioReverbFactoryPresets::profiles[static_cast(type)]; + juce::MemoryBlock authoredState; + const bool built = OpenStudioReverbFactoryPresets::buildState(voice.name, authoredState); + passed = built && passed; + const auto stateFile = juce::String("canonical-type-") + juce::String(type).paddedLeft('0', 2) + ".state"; + passed = directory.getChildFile(stateFile).replaceWithData(authoredState.getData(), authoredState.getSize()) && passed; + for (size_t material = 0; material < materials.size(); ++material) + { + std::array, 6> outputs; + for (int variant = 0; variant < 6; ++variant) outputs[static_cast(variant)] = render(authoredState, material, variant); + auto* files = new juce::DynamicObject(); auto* peaks = new juce::DynamicObject(); + auto* unityCounts = new juce::DynamicObject(); auto* boundCounts = new juce::DynamicObject(); + bool finite = true, written = true; + double dryEnergy = 0, wet50Energy = 0, wet100Energy = 0, originalEnergy = 0; + double halfError = 0, gainError = 0, tailEnergy = 0; + const float gain = BuiltInReverbReturnLevels::linear[static_cast(type)]; + for (int variant = 0; variant < 6; ++variant) + { + const auto filename = juce::String(type).paddedLeft('0', 2) + "-" + materials[material] + "-" + labels[static_cast(variant)] + ".wav"; + files->setProperty(labels[static_cast(variant)], filename); + written = writeProbeWave(directory.getChildFile(filename), outputs[static_cast(variant)], rate) && written; + double peak = 0; juce::int64 unity = 0, bound = 0; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < length; ++sample) + { + const double value = outputs[static_cast(variant)].getSample(channel, sample); + finite = std::isfinite(value) && finite; peak = juce::jmax(peak, std::abs(value)); + if (std::abs(value) > 1) ++unity; + if (std::abs(value) >= 4) ++bound; + } + peaks->setProperty(labels[static_cast(variant)], peak); + unityCounts->setProperty(labels[static_cast(variant)], unity); boundCounts->setProperty(labels[static_cast(variant)], bound); + aboveUnity += unity; atSafetyBound += bound; maximumPeak = juce::jmax(maximumPeak, peak); + } + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < length; ++sample) + { + const double dry = outputs[0].getSample(channel, sample); + const double original = outputs[1].getSample(channel, sample) - dry; + const double half = outputs[3].getSample(channel, sample) - dry; + const double full = outputs[5].getSample(channel, sample) - dry; + for(int level=2;level<6;++level)halfError=juce::jmax(halfError,std::abs(outputs[static_cast(level)].getSample(channel,sample)-dry-(level-1)*.25*full)); + gainError = juce::jmax(gainError, std::abs(full - gain * original)); + if (sample < excitationFrames[material]) { dryEnergy += dry * dry; originalEnergy += original * original; wet50Energy += half * half; wet100Energy += full * full; } + else tailEnergy += full * full; + } + maximumHalfError = juce::jmax(maximumHalfError, halfError); maximumGainError = juce::jmax(maximumGainError, gainError); + const auto rms = [material, &excitationFrames] (double energy) { return std::sqrt(energy / (2 * juce::jmax(1, excitationFrames[material]))); }; + const auto ratio = [] (double wet, double dry) { return 10 * std::log10(juce::jmax(1e-30, wet) / juce::jmax(1e-30, dry)); }; + auto* row = new juce::DynamicObject(); row->setProperty("type", type); row->setProperty("name", voice.name); + row->setProperty("outputTrimDb",profileOutput?voice.outputTrimDb:0);row->setProperty("material", materials[material]); row->setProperty("files", juce::var(files)); row->setProperty("peaks", juce::var(peaks)); + row->setProperty("samplesAboveUnityDiagnostic", juce::var(unityCounts)); row->setProperty("samplesAtSafetyBoundDiagnostic", juce::var(boundCounts)); + row->setProperty("excitationSeconds", static_cast(excitationFrames[material]) / rate); + row->setProperty("dryRms", rms(dryEnergy)); row->setProperty("originalWet100Rms", rms(originalEnergy)); + row->setProperty("wet50Rms", rms(wet50Energy)); row->setProperty("wet100Rms", rms(wet100Energy)); + row->setProperty("original100VsDryDb", ratio(originalEnergy, dryEnergy)); + row->setProperty("wet25VsDryDb",ratio(wet100Energy*.0625,dryEnergy));row->setProperty("wet75VsDryDb",ratio(wet100Energy*.5625,dryEnergy));row->setProperty("wet50VsDryDb", ratio(wet50Energy, dryEnergy)); row->setProperty("wet100VsDryDb", ratio(wet100Energy, dryEnergy)); + row->setProperty("calibrationDb", BuiltInReverbReturnLevels::decibels[static_cast(type)]); + row->setProperty("wetHalfMaxError", halfError); row->setProperty("calibrationMaxError", gainError); + row->setProperty("wetTailEnergy", tailEnergy); row->setProperty("finite", finite); row->setProperty("filesWritten", written); + const bool rowPass = built && finite && written && dryEnergy > 0 && wet100Energy > 0 && halfError < 2e-5 && gainError < 2e-5; + row->setProperty("pass", rowPass); passed = rowPass && passed; rows.add(juce::var(row)); + } + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Standalone canonical Reverb native mixed generated/recorded-DI qualification"); + result->setProperty("pass", passed); result->setProperty("cases", rows); result->setProperty("directory", directory.getFullPathName()); + result->setProperty("sampleRate", rate); result->setProperty("seconds", 10); result->setProperty("sources", inputRows); + juce::Array materialNames; for (const auto* material : materials) materialNames.add(material); + result->setProperty("materials", materialNames); result->setProperty("caseCount", rows.size()); + result->setProperty("dry", .7); result->setProperty("outputDb",profileOutput?juce::var("authored_per_voice"):juce::var(0)); result->setProperty("processing", "Each canonical voice retains its authored Processing and character state"); + result->setProperty("commonInputGain", commonInputGain); result->setProperty("maximumHalfError", maximumHalfError); result->setProperty("maximumGainError", maximumGainError); + result->setProperty("maximumMixedPeak", maximumPeak); result->setProperty("samplesAboveUnityDiagnostic", aboveUnity); result->setProperty("samplesAtSafetyBoundDiagnostic", atSafetyBound); + result->setProperty("levels", "Native mixed output without output normalization. Every source has the same fixed0.5 input gain; recorded-source preparation gains are separately recorded. Residual wet = mixed minus separately rendered native dry."); + result->setProperty("recordedSourceManifest", "recorded-source-manifest.json"); + result->setProperty("rmsBalance", "diagnostic_only"); result->setProperty("audibility", "not_asserted"); + result->setProperty("dominance", "not_asserted"); result->setProperty("audioQuality", "not_asserted"); + result->setProperty("realRecordedVoice", "Short JUCE singing sample only; performer/recording-chain provenance, binary licence credits and broad vocal qualification remain unasserted"); + result->setProperty("tailCompleteness", "not_asserted_in_ten_second_audition_pack; separate extended qualification observes finite declared tails"); + return juce::var(result); +} diff --git a/Source/FreePluginSettledReverbRegression.h b/Source/FreePluginSettledReverbRegression.h new file mode 100644 index 0000000..db545cf --- /dev/null +++ b/Source/FreePluginSettledReverbRegression.h @@ -0,0 +1,293 @@ +#pragma once +#include "BuiltInSettledReverbTap.h" +#include + +// Every measured decay below is diagnostic: finite windows, output filters, +// nonlinear colour and nonuniform/modal envelopes prevent an acoustic RT60 +// assertion. The deterministic checks concern read policy and state continuity. +inline juce::var settledReverbBandDecay(const juce::AudioBuffer& audio, double rate) +{ + juce::Array bands; + for (double frequency : {125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0}) + { + std::array, 2> filters; + for (auto& filter : filters) + filter.coefficients = juce::dsp::IIR::Coefficients::makeBandPass(rate, static_cast(frequency), .70710678f); + std::vector energy(static_cast(audio.getNumSamples())); + for (int sample = 0; sample < audio.getNumSamples(); ++sample) + for (int channel = 0; channel < 2; ++channel) + { + const double value = filters[static_cast(channel)].processSample(audio.getSample(channel, sample)); + energy[static_cast(sample)] += value * value; + } + double cumulative = 0; + for (int sample = audio.getNumSamples(); --sample >= 0;) + { cumulative += energy[static_cast(sample)]; energy[static_cast(sample)] = cumulative; } + double x = 0, y = 0, xx = 0, xy = 0; + int count = 0; + if (cumulative > 0) + for (int sample = 0; sample < audio.getNumSamples(); ++sample) + { + const double db = 10 * std::log10(juce::jmax(1e-300, energy[static_cast(sample)] / cumulative)); + if (db < -5 && db > -25) + { const double time = sample / rate; x += time; y += db; xx += time * time; xy += time * db; ++count; } + } + const double denominator = count * xx - x * x; + const double slope = count > 8 && denominator > 0 ? (count * xy - x * y) / denominator : 0; + auto* band = new juce::DynamicObject(); + band->setProperty("centerHz", frequency); band->setProperty("capturedEnergy", cumulative); + band->setProperty("fitSamples", count); band->setProperty("diagnostic_only", true); + if (slope < 0) band->setProperty("t20ExtrapolatedSeconds", -60 / slope); + bands.add(band); + } + return bands; +} + +template +juce::AudioBuffer renderSettledReverbCase(double rate, int mode, Settings settings, + const std::vector& blocks, bool changes, double seconds, SetMotion&& setMotion, float rightRatio = -.31f) +{ + auto engine = std::make_unique(); + engine->prepare(rate, mode); + engine->configure(mode, settings, false); + const int length = juce::roundToInt(rate * seconds), warm = juce::roundToInt(rate * .12); + for (int sample = 0; sample < warm; ++sample) engine->process(0, 0); + juce::AudioBuffer output(2, length); + const std::array events { + juce::roundToInt(rate * .15), juce::roundToInt(rate * .25), juce::roundToInt(rate * .30), + juce::roundToInt(rate * .40), juce::roundToInt(rate * .55), juce::roundToInt(rate * .65) + }; + size_t pattern = 0, event = 0; + for (int start = 0; start < length;) + { + if (changes && event < events.size() && start == events[event]) + { + switch (event) + { + case 0: settings.refined = !settings.refined; break; + case 1: settings.refined = true; settings.size = .81f; break; + case 2: setMotion(settings, true); break; + case 3: settings.hold = true; settings.holdInput = false; break; + case 4: settings.hold = false; settings.size = .23f; setMotion(settings, false); break; + case 5: settings.refined = false; break; + default: break; + } + ++event; + } + int count = juce::jmin(blocks[pattern++ % blocks.size()], length - start); + if (changes && event < events.size()) count = juce::jmin(count, events[event] - start); + engine->configure(mode, settings, false); + for (int sample = 0; sample < count; ++sample) + { + const int position = start + sample; + const float input = changes ? .035f * std::sin(static_cast(position) * .073f) + : position == 0 ? .25f : 0; + const auto value = engine->process(input, input * rightRatio); + output.setSample(0, position, value[0]); output.setSample(1, position, value[1]); + } + start += count; + } + return output; +} + +inline juce::var checkSettledReverbInterpolation() +{ + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Opt-in settled integer reverb taps"); + bool policy = true, finite = true, partition = true, monoPartition = true, movingParity = true; + bool hold = true, influence = true, files = true; + double maximumPartitionError = 0, maximumPeak = 0; + juce::Array cases, policyCases, theoretical; + const std::vector regular {512}, irregular {1, 17, 113, 7, 251}; + const auto difference = [](const auto& a, const auto& b) + { + double error = 0; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < a.getNumSamples(); ++sample) + error = juce::jmax(error, std::abs(static_cast(a.getSample(channel, sample) - b.getSample(channel, sample)))); + return error; + }; + const auto sane = [&finite, &maximumPeak](const auto& audio) + { + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < audio.getNumSamples(); ++sample) + { + const double value = audio.getSample(channel, sample); + finite = std::isfinite(value) && finite; + maximumPeak = juce::jmax(maximumPeak, std::abs(value)); + } + }; + policy = !BuiltInClearReverb::Settings{}.refined && !BuiltInDriftingReverb::Settings{}.refined + && !BuiltInRetroReverb::Settings{}.refined; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + { + BuiltInSettledReverbTap tap; tap.prepare(rate); + int integerReads = 0; + const auto integerRead = [&integerReads](float anchor) { ++integerReads; return anchor * .01f; }; + bool exactOriginalBypass = true; + for (float original : {0.0f, -0.0f, .23f, -.81f, 1.23f}) + { + const float value = tap.process(original, 10.37f, false, true, integerRead); + exactOriginalBypass = value == original && std::signbit(value) == std::signbit(original) && exactOriginalBypass; + } + exactOriginalBypass = integerReads == 0 && exactOriginalBypass; + // First stationary refined sample can initialize at integer; all later + // policy/anchor changes are ramps with no changing integer read head. + tap.reset(); const float initial = tap.process(.23f, 10.37f, true, true, integerRead); + const bool initialIntegerSelection = initial == 10 * .01f && tap.integerBlend() == 1 && tap.integerAnchor() == 10; + const int ramp = static_cast(std::floor(rate * .02)); + BuiltInSettledReverbTap bypassTap; bypassTap.prepare(rate); + const bool originalFastBypass = !bypassTap.needsProcessing(false); + bypassTap.process(.23f, 10.37f, true, true, integerRead); + const bool liveEnableFades = bypassTap.integerBlend() > 0 && bypassTap.integerBlend() < 1 + && bypassTap.integerAnchor() == 10; + bool bypassWaitsForFade = bypassTap.needsProcessing(false); + for (int sample = 0; sample < ramp + 1; ++sample) + { + if (bypassTap.integerBlend() > 0) + bypassWaitsForFade = bypassTap.needsProcessing(false) && bypassWaitsForFade; + bypassTap.process(.23f, 10.37f, false, true, integerRead); + } + const bool originalBypassResumes = bypassTap.integerBlend() == 0 && !bypassTap.needsProcessing(false); + float previous = initial; + double maximumStep = 0; + bool movingFixedAnchor = true, movingMonotonic = true; + for (int sample = 0; sample < ramp + 1; ++sample) + { + const float value = tap.process(.23f, 20 + static_cast(sample), true, false, integerRead); + maximumStep = juce::jmax(maximumStep, std::abs(static_cast(value - previous))); + movingFixedAnchor = tap.integerAnchor() == 10 && movingFixedAnchor; + movingMonotonic = value >= previous && movingMonotonic; previous = value; + } + const bool movingFadeSettles = tap.integerBlend() == 0 && previous == .23f; + const double stepBound = .13 / ramp + 1e-6; + const bool boundedFadeStep = maximumStep < stepBound; + tap.process(.23f, 27.63f, true, true, integerRead); + const bool stationaryFadeStarts = tap.integerAnchor() == 28 && tap.integerBlend() > 0 && tap.integerBlend() < 1; + for (int sample = 0; sample < ramp; ++sample) tap.process(.23f, 27.63f, true, true, integerRead); + const bool stationaryFadeSettles = tap.integerAnchor() == 28 && tap.integerBlend() == 1; + tap.process(.23f, 37.12f, true, true, integerRead); + bool anchorChangesOnlyAtZero = tap.integerAnchor() == 28 && tap.integerBlend() < 1; + for (int sample = 0; sample < ramp + 1; ++sample) + { + const float oldAnchor = tap.integerAnchor(), oldBlend = tap.integerBlend(); + tap.process(.23f, 37.12f, true, true, integerRead); + anchorChangesOnlyAtZero = (tap.integerAnchor() == oldAnchor || oldBlend == 0) && anchorChangesOnlyAtZero; + } + const bool finalAnchorSelected = tap.integerAnchor() == 37; + policy = exactOriginalBypass && initialIntegerSelection && movingFixedAnchor && movingMonotonic + && movingFadeSettles && boundedFadeStep && stationaryFadeStarts && stationaryFadeSettles + && anchorChangesOnlyAtZero && finalAnchorSelected && originalFastBypass && liveEnableFades + && bypassWaitsForFade && originalBypassResumes && policy; + auto* row = new juce::DynamicObject(); row->setProperty("sampleRate", rate); + row->setProperty("maximumPolicyStep", maximumStep); row->setProperty("policyStepBound", stepBound); + row->setProperty("exactOriginalBypass", exactOriginalBypass); + row->setProperty("initialIntegerSelection", initialIntegerSelection); + row->setProperty("originalFastBypass", originalFastBypass); row->setProperty("liveEnableFades", liveEnableFades); + row->setProperty("bypassWaitsForFade", bypassWaitsForFade); row->setProperty("originalBypassResumes", originalBypassResumes); + row->setProperty("movingFixedAnchor", movingFixedAnchor); row->setProperty("movingMonotonic", movingMonotonic); + row->setProperty("movingFadeSettles", movingFadeSettles); row->setProperty("boundedFadeStep", boundedFadeStep); + row->setProperty("stationaryFadeStarts", stationaryFadeStarts); row->setProperty("stationaryFadeSettles", stationaryFadeSettles); + row->setProperty("anchorChangesOnlyAtZero", anchorChangesOnlyAtZero); row->setProperty("finalAnchorSelected", finalAnchorSelected); + policyCases.add(row); + + const auto exercise = [&](auto* engineTag, const char* family, int mode, auto settings, auto setMotion) + { + using Engine = std::remove_pointer_t; + settings.refined = true; + const auto a = renderSettledReverbCase(rate, mode, settings, irregular, true, .75, setMotion); + const auto b = renderSettledReverbCase(rate, mode, settings, regular, true, .75, setMotion); + const double error = difference(a, b); maximumPartitionError = juce::jmax(maximumPartitionError, error); + partition = error == 0 && partition; sane(a); sane(b); + const auto monoA = renderSettledReverbCase(rate, mode, settings, irregular, true, .75, setMotion, 1); + const auto monoB = renderSettledReverbCase(rate, mode, settings, regular, true, .75, setMotion, 1); + const double monoError = difference(monoA, monoB); + maximumPartitionError = juce::jmax(maximumPartitionError, monoError); + monoPartition = monoError == 0 && monoPartition; sane(monoA); sane(monoB); + auto moving = settings; moving.refined = false; setMotion(moving, true); + const auto movingOriginal = renderSettledReverbCase(rate, mode, moving, regular, false, .25, setMotion); + moving.refined = true; + const auto movingRefined = renderSettledReverbCase(rate, mode, moving, regular, false, .25, setMotion); + sane(movingOriginal); sane(movingRefined); + const double movingError = difference(movingOriginal, movingRefined); + movingParity = movingError == 0 && movingParity; + settings.hold = true; settings.holdInput = false; + const auto silent = renderSettledReverbCase(rate, mode, settings, irregular, false, .18, setMotion); + sane(silent); + hold = silent.getMagnitude(0, silent.getNumSamples()) == 0 && hold; + settings.holdInput = true; + const auto accepted = renderSettledReverbCase(rate, mode, settings, irregular, false, .30, setMotion); + hold = accepted.getMagnitude(0, accepted.getNumSamples()) > 0 && hold; sane(accepted); + auto* item = new juce::DynamicObject(); item->setProperty("family", family); item->setProperty("mode", mode); + item->setProperty("sampleRate", rate); item->setProperty("partitionError", error); + item->setProperty("monoExcitationPartitionError", monoError); + item->setProperty("movingOriginalParityError", movingError); cases.add(item); + }; + BuiltInClearReverb::Settings clear; clear.size = .413f; clear.depth = 0; clear.decay = .6f; clear.damping = 0; + const auto clearMotion = [](auto& settings, bool enabled) { settings.depth = enabled ? .55f : 0; }; + BuiltInDriftingReverb::Settings drift; drift.size = .413f; drift.wow = drift.flutter = 0; drift.drive = 0; drift.decay = .6f; drift.damping = 0; + const auto driftMotion = [](auto& settings, bool enabled) { settings.wow = enabled ? .55f : 0; settings.flutter = enabled ? .2f : 0; }; + BuiltInRetroReverb::Settings retro; retro.size = .413f; retro.depth = 0; retro.decay = .6f; retro.damping = 0; retro.attack = .25f; + const auto retroMotion = [](auto& settings, bool enabled) { settings.depth = enabled ? .55f : 0; }; + for (int mode : {0, 1, 2}) exercise(static_cast(nullptr), "Clear", mode, clear, clearMotion); + for (int mode : {0, 1, 2}) exercise(static_cast(nullptr), "Drifting", mode, drift, driftMotion); + // Core2 is intentionally inert and has no Hold. Test it separately below. + for (int mode : {0, 3, 12}) exercise(static_cast(nullptr), "Retro", mode, retro, retroMotion); + } + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile("output/reverb-refinement/responses"); + files = directory.createDirectory().wasOk(); + juce::Array decayCases; + const auto diagnose = [&](auto* engineTag, const char* family, int mode, auto settings, auto setMotion, bool affected) + { + using Engine = std::remove_pointer_t; + const auto original = renderSettledReverbCase(48000, mode, settings, regular, false, 3, setMotion); + settings.refined = true; + const auto refined = renderSettledReverbCase(48000, mode, settings, regular, false, 3, setMotion); + sane(original); sane(refined); + const double error = difference(original, refined); + influence = (affected ? error > 0 : error == 0) && influence; + const auto stem = juce::String(family) + "-" + juce::String(mode); + const auto originalName = stem + "-Original.wav", refinedName = stem + "-Refined.wav"; + files = writeProbeWave(directory.getChildFile(originalName), original, 48000) + && writeProbeWave(directory.getChildFile(refinedName), refined, 48000) && files; + auto* row = new juce::DynamicObject(); row->setProperty("family", family); row->setProperty("mode", mode); + row->setProperty("originalFile", originalName); row->setProperty("refinedFile", refinedName); + row->setProperty("maximumDifferenceDiagnostic", error); row->setProperty("declaredDecaySeconds", settings.decay); + row->setProperty("originalBandsDiagnostic", settledReverbBandDecay(original, 48000)); + row->setProperty("refinedBandsDiagnostic", settledReverbBandDecay(refined, 48000)); decayCases.add(row); + }; + BuiltInClearReverb::Settings clear; clear.size = .413f; clear.depth = 0; clear.decay = .6f; clear.damping = 0; clear.inputDiffusion = 0; + BuiltInDriftingReverb::Settings drift; drift.size = .413f; drift.wow = drift.flutter = 0; drift.drive = 0; drift.decay = .6f; drift.damping = 0; + BuiltInRetroReverb::Settings retro; retro.size = .413f; retro.depth = 0; retro.decay = .6f; retro.damping = 0; retro.inputDiffusion = 0; + const auto noMotion = [](auto&, bool) {}; + for (int mode : {0, 1, 2}) diagnose(static_cast(nullptr), "Clear", mode, clear, noMotion, true); + for (int mode : {0, 1, 2}) diagnose(static_cast(nullptr), "Drifting", mode, drift, noMotion, true); + for (int mode : {0, 3, 5, 9, 11, 12}) diagnose(static_cast(nullptr), "Retro", mode, retro, noMotion, mode != 9); + // Analytic one-loop screen separates interpolation loss from a model's + // intentional damping. It is not a calibration of any complete reverb. + constexpr double loopLength = 997.37, nominalDecay = 1.5, rate = 48000, fraction = .37; + const double gain = std::pow(.001, loopLength / (rate * nominalDecay)); + for (double frequency : {125.0, 1000.0, 4000.0, 8000.0}) + { + const double magnitude = std::sqrt(1 - 2 * fraction * (1 - fraction) + * (1 - std::cos(juce::MathConstants::twoPi * frequency / rate))); + auto* band = new juce::DynamicObject(); band->setProperty("centerHz", frequency); + band->setProperty("linearInterpolationMagnitude", magnitude); + band->setProperty("originalEquivalentLoopDecaySeconds", std::log(.001) * loopLength / rate / std::log(gain * magnitude)); + band->setProperty("integerEquivalentLoopDecaySeconds", std::log(.001) * std::round(loopLength) / rate / std::log(gain)); + theoretical.add(band); + } + result->setProperty("cases", cases); result->setProperty("policyCases", policyCases); + result->setProperty("readPolicy", policy); result->setProperty("exactIrregularPartitioning", partition); + result->setProperty("exactMonoExcitationPartitioning", monoPartition); + result->setProperty("movingOriginalParity", movingParity); + result->setProperty("maximumPartitionError", maximumPartitionError); result->setProperty("finiteLiveTransitions", finite); + result->setProperty("maximumPeakDiagnostic", maximumPeak); result->setProperty("holdInputPolicies", hold); + result->setProperty("staticInfluenceAndFiniteReflectionBypass", influence); result->setProperty("responseFiles", files); + result->setProperty("bandDecayDiagnostics", decayCases); result->setProperty("singleLoopTheoryDiagnostics", theoretical); + result->setProperty("directory", directory.getFullPathName()); + result->setProperty("audioQuality", "not_asserted"); result->setProperty("decayCalibration", "diagnostic_only"); + result->setProperty("limitations", "Three-second band-decay fits include output/damping filters; nested-loop mean-decay heuristic remains unchanged; static rounded delays differ from nominal gain lengths by at most half a sample. Moving interpolation is unchanged, with a 20ms fade of a fixed old integer anchor."); + result->setProperty("pass", policy && finite && partition && monoPartition && movingParity && hold && influence && files); + return result; +} diff --git a/Source/FreePluginShimmerPerformanceRegression.h b/Source/FreePluginShimmerPerformanceRegression.h new file mode 100644 index 0000000..830c7b9 --- /dev/null +++ b/Source/FreePluginShimmerPerformanceRegression.h @@ -0,0 +1,181 @@ +#pragma once +#include "BuiltInShimmerScheduling.h" + +namespace ShimmerPerformanceRegression +{ +// Retained pre-optimization adapter as a signal oracle, not a production path. +class PreviousSpreadVoice +{ +public: + void prepare(double rate, int phase) + { + stretch.presetDefault(2, static_cast(rate), true); + stretch.setTransposeFactor(1); + for (int i = 0; i < stretch.intervalSamples() * phase / 4; ++i) process(0, 0, 1); + } + std::array process(float left, float right, float ratio) + { + const auto slot = static_cast(position); + const std::array result{output[0][slot], output[1][slot]}; + input[0][slot] = left; input[1][slot] = right; + if (++position == 64) + { + position = 0; stretch.setTransposeFactor(ratio); + const float* in[]{input[0].data(), input[1].data()}; + float* out[]{output[0].data(), output[1].data()}; + stretch.process(in, 64, out, 64); + } + return result; + } +private: + signalsmith::stretch::SignalsmithStretch stretch{0}; + std::array, 2> input{}, output{}; + int position = 0; +}; +} + +inline juce::var checkShimmerPerformanceBehavior() +{ + juce::Array rows; bool pass = true; + int allocations = 0, frees = 0; bool heapMeasured = false; + const auto beginHeap = [] { +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations = ReverbCompatibilityHeapProbe::frees = 0; + ReverbCompatibilityHeapProbe::previous = _CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook); + ReverbCompatibilityHeapProbe::active = true; +#endif + }; + const auto endHeap = [&] { +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active = false; + _CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous); + allocations += ReverbCompatibilityHeapProbe::allocations; + frees += ReverbCompatibilityHeapProbe::frees; heapMeasured = true; +#endif + }; + const auto add = [&](juce::DynamicObject* row, bool good) { + row->setProperty("pass", good); rows.add(row); pass = pass && good; + }; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) for (int role = 0; role < 4; ++role) + for (bool afterSilence : {false, true}) + { + ShimmerPerformanceRegression::PreviousSpreadVoice previous; previous.prepare(rate, role); + BuiltInSpreadReverbPitchVoice current, deferred; current.prepare(rate, role); deferred.prepare(rate, role); + // Populate analysis, synthesis and phase history, including at 192k. + for (int i = 0; i < static_cast(rate*.35); ++i) + deferred.process(.1f*std::sin(.037f*static_cast(i)),-.07f*std::cos(.051f*static_cast(i)),2); + double previousError = 0, deferredError = 0, energy = 0; bool finite = true; + std::uint32_t noise = 17; + beginHeap(); deferred.beginReset(); + int clearSteps=0; + do { ++clearSteps; } while(!deferred.resetStep()); + int primeSteps = 0; + do { ++primeSteps; } while (!deferred.primeStep()); + for (int i = 0; i < static_cast(rate * (afterSilence ? 1.0 : .7)); ++i) + { + noise = noise * 1664525u + 1013904223u; + float input = static_cast(.08 * std::sin(i * 2 * juce::MathConstants::pi * 223 / rate) + + .031 * std::cos(i * 2 * juce::MathConstants::pi * 997 / rate)); + if (afterSilence) input = i < static_cast(rate * .35) ? 0 + : input + .02f * (static_cast(noise >> 8) / 16777215.0f - .5f); + const float ratio = std::array{.5f, 1, 2, std::pow(2.0f, 19.0f / 12.0f)}[static_cast(role)]; + const auto a = previous.process(input, -.73f * input, ratio); + const auto b = current.process(input, -.73f * input, ratio); + const auto c = deferred.process(input, -.73f * input, ratio); + for (size_t ch = 0; ch < 2; ++ch) + { + finite = finite && std::isfinite(b[ch]) && std::isfinite(c[ch]); energy += b[ch] * b[ch]; + previousError = juce::jmax(previousError, std::abs(static_cast(a[ch]) - b[ch])); + deferredError = juce::jmax(deferredError, std::abs(static_cast(b[ch]) - c[ch])); + } + } + endHeap(); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "fine_fft_and_incremental_reset_signal_parity"); + row->setProperty("sampleRate", rate); row->setProperty("role", role); + row->setProperty("afterSilenceBroadband", afterSilence); + row->setProperty("previousAdapterMaxError", previousError); row->setProperty("deferredResetMaxError", deferredError); + row->setProperty("primeSteps", primeSteps); + row->setProperty("boundedResetSteps",clearSteps); + add(row, finite && energy > .001 && previousError < 1e-5 && deferredError == 0 + && clearSteps==deferred.resetSamples() && clearSteps>1 + && primeSteps == juce::jmax(1, deferred.primingSamples())); + } + for(double rate:{44100.0,48000.0,96000.0,192000.0}) + { + BuiltInReverbPitchVoice fresh,deferred; + fresh.prepare(rate);deferred.prepare(rate); + for(int sample=0;sample(rate*.35);++sample) + deferred.process(.1f*std::sin(.037f*static_cast(sample)),-.07f*std::cos(.051f*static_cast(sample)),2); + beginHeap();deferred.beginReset(); + int steps=0;do{++steps;}while(!deferred.resetStep()); + double maximumError=0,energy=0; + for(int sample=0;sample(rate*.45);++sample) + { + const float input=.08f*std::sin(.029f*static_cast(sample)); + const auto expected=fresh.process(input,-.73f*input,1.5f); + const auto actual=deferred.process(input,-.73f*input,1.5f); + for(size_t channel=0;channel<2;++channel) + { + maximumError=juce::jmax(maximumError,std::abs(static_cast(actual[channel])-expected[channel])); + energy+=static_cast(actual[channel])*actual[channel]; + } + } + endHeap(); + auto* row=new juce::DynamicObject();row->setProperty("kind","original_populated_history_incremental_reset_parity"); + row->setProperty("sampleRate",rate);row->setProperty("boundedResetSteps",steps);row->setProperty("maxError",maximumError); + add(row,maximumError==0&&energy>.001&&steps==deferred.resetSamples()&&steps>1); + } + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) for (bool spread : {false, true}) + { + auto engine = std::make_unique(); engine->prepare(rate, 5); + BuiltInAdditionalReverbs::Settings settings{.1f, .3f, .6f, .7f, 0, 20, 10000, 1, 1}; + settings.spreadShimmer = spread; settings.shimmerRoute = 1; settings.voiceMix = .5f; + engine->configure(5, settings); + bool finite = true, gate = true; + beginHeap(); + for (int i = 0; i < static_cast(rate * .4); ++i) + { + const auto out = engine->process(i < 100 ? .1f : 0, 0); + finite = finite && std::isfinite(out[0]) && std::isfinite(out[1]); + } + const auto inputFrames = engine->shimmerTankPitchProcessedFrames(); + const bool slept = engine->shimmerTankPitchSuspended(); + settings.shimmerRoute = 2; engine->configure(5, settings); + engine->process(0, 0); + const int warmup = engine->shimmerTankPitchWarmupRemaining(); + gate = !spread || (warmup > 0 && engine->shimmerTankRouteGain() == 0); + // Cancel before history is ready; no partially warmed return is allowed. + for (int i = 0; i < static_cast(rate * .03); ++i) engine->process(0, 0); + settings.shimmerRoute = 1; engine->configure(5, settings); + for (int i = 0; i < static_cast(rate * .06); ++i) engine->process(0, 0); + gate = gate && (!spread || engine->shimmerTankPitchSuspended()); + // Wake again, and automate pitch while refilling. + settings.shimmerRoute = 0; settings.pitchA = -12; settings.pitchB = 19; + engine->configure(5, settings); + for (int i = 0; i < static_cast(rate * .4); ++i) + { + const auto out = engine->process(0, 0); + finite = finite && std::isfinite(out[0]) && std::isfinite(out[1]); + if (engine->shimmerTankPitchWarmupRemaining() > 0) gate = gate && engine->shimmerTankRouteGain() == 0; + } + gate = gate && !engine->shimmerTankPitchSuspended() && engine->shimmerTankPitchWarmupRemaining() == 0 + && engine->shimmerTankRouteGain() == 1; + double staleEnergy = 0; + for (int i = 0; i < static_cast(rate * .5); ++i) + { + const auto out = engine->process(0, 0); + staleEnergy += out[0] * out[0] + out[1] * out[1]; + } + endHeap(); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "input_sleep_interrupted_route_wakeup_no_stale_return"); + row->setProperty("sampleRate", rate); row->setProperty("spread", spread); + row->setProperty("inputTankProcessedFrames", static_cast(inputFrames)); + row->setProperty("wakeSamples", warmup); row->setProperty("staleEnergy", staleEnergy); + add(row, finite && gate && slept == spread && (spread ? inputFrames == 0 : inputFrames > 0) && staleEnergy < 1e-8); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Shimmer performance signal and lifecycle gates"); + result->setProperty("cases", rows); result->setProperty("pass", pass && allocations == 0 && frees == 0); + result->setProperty("callbackHeapMeasured", heapMeasured); result->setProperty("callbackAllocations", allocations); + result->setProperty("callbackFrees", frees); result->setProperty("audioQuality", "not_asserted"); + return juce::var(result); +} diff --git a/Source/FreePluginShimmerSchedulingRegression.h b/Source/FreePluginShimmerSchedulingRegression.h new file mode 100644 index 0000000..5ab541a --- /dev/null +++ b/Source/FreePluginShimmerSchedulingRegression.h @@ -0,0 +1,622 @@ +#pragma once +#include "BuiltInShimmerScheduling.h" + +namespace ShimmerSchedulingRegression +{ +inline double error(const juce::AudioBuffer& a, const juce::AudioBuffer& b) +{ + if (a.getNumChannels() != b.getNumChannels() || a.getNumSamples() != b.getNumSamples()) + return std::numeric_limits::infinity(); + double maximum = 0; + for (int channel = 0; channel < a.getNumChannels(); ++channel) + for (int sample = 0; sample < a.getNumSamples(); ++sample) + maximum = juce::jmax(maximum, std::abs(static_cast(a.getSample(channel, sample)) + - static_cast(b.getSample(channel, sample)))); + return maximum; +} +struct Capture +{ + juce::AudioBuffer audio; + bool ready = true, finite = true, midFadePhaseProof = true; + int midFadeReversals = 0; + double peak = 0; +}; +struct RenderOptions +{ + double rate = 48000; + int channels = 2, mode = 0, route = 0, preparedBlock = 128; + bool irregular = false, live = false, reprepare = false, dryOnly = false, explicitReset = false; + bool routeAutomation = false; + const juce::MemoryBlock* state = nullptr; +}; +inline juce::MemoryBlock bytes(const juce::ValueTree& tree) +{ + juce::MemoryBlock result; + { juce::MemoryOutputStream stream(result, false); tree.writeToStream(stream); } + return result; +} +inline std::int64_t privateBytes() +{ +#if JUCE_WINDOWS + PROCESS_MEMORY_COUNTERS_EX counters{}; counters.cb = static_cast(sizeof(counters)); + if (GetProcessMemoryInfo(GetCurrentProcess(), reinterpret_cast(&counters), static_cast(sizeof(counters)))) + return static_cast(counters.PrivateUsage); +#endif + return -1; +} +} + +inline juce::var checkShimmerScheduling() +{ + using namespace ShimmerSchedulingRegression; + bool passed = true, heapMeasured = false; + int allocations = 0, frees = 0; + juce::Array cases; + const auto add = [&] (juce::DynamicObject* row, bool pass) { + row->setProperty("pass", pass); cases.add(juce::var(row)); passed = pass && passed; + }; + const auto processChecked = [&] (OpenStudioReverb& processor, juce::AudioBuffer& block, juce::MidiBuffer& midi) { +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::allocations = ReverbCompatibilityHeapProbe::frees = 0; + ReverbCompatibilityHeapProbe::previous = _CrtSetAllocHook(ReverbCompatibilityHeapProbe::hook); + ReverbCompatibilityHeapProbe::active = true; +#endif + processor.processBlock(block, midi); +#if JUCE_WINDOWS && JUCE_DEBUG + ReverbCompatibilityHeapProbe::active = false; + _CrtSetAllocHook(ReverbCompatibilityHeapProbe::previous); + allocations += ReverbCompatibilityHeapProbe::allocations; + frees += ReverbCompatibilityHeapProbe::frees; + heapMeasured = true; +#endif + }; + const auto render = [&] (const RenderOptions& options) { + auto processor = std::make_unique(true); + if (options.state) processor->setStateInformation(options.state->getData(), static_cast(options.state->getSize())); + processor->selectAlgorithm(5); processor->shimmerScheduling.store(static_cast(options.mode)); + processor->creativeControls[0].store(static_cast(options.route)); + processor->shimmerVoiceMix.store(.5f); processor->shimmerAmount.store(1); + processor->wetLevel.store(options.dryOnly ? 0.0f : 1.0f); processor->dryLevel.store(options.dryOnly ? .7f : 0.0f); + processor->reverbReturnMode.store(0); processor->outputTrimDb.store(0); + auto layout = processor->getBusesLayout(); + layout.inputBuses.set(0, options.channels == 1 ? juce::AudioChannelSet::mono() : juce::AudioChannelSet::stereo()); + layout.outputBuses.set(0, options.channels == 1 ? juce::AudioChannelSet::mono() : juce::AudioChannelSet::stereo()); + Capture capture; capture.ready = processor->setBusesLayout(layout); + processor->prepareToPlay(options.rate, options.preparedBlock); + juce::AudioBuffer block(processor->getTotalNumOutputChannels(), 1021); juce::MidiBuffer midi; + if (options.reprepare) + { + for (int iteration = 0; iteration < 17; ++iteration) + { + block.setSize(block.getNumChannels(), 127, false, false, true); block.clear(); + block.setSample(0, 0, .1f); processChecked(*processor, block, midi); + } + processor->prepareToPlay(options.rate, options.preparedBlock); + } + if (options.explicitReset) processor->reset(); + const int length = juce::roundToInt(options.rate * ((options.live || options.routeAutomation) ? 1.65 : .65)); + capture.audio.setSize(options.channels, length); + const std::array eventSeconds{.25, .31, .55, .740, .745, .83, .90, .995, 1.06, 1.20}; + std::array events{}; + for (size_t index = 0; index < events.size(); ++index) events[index] = juce::roundToInt(options.rate * eventSeconds[index]); + BuiltInSpreadReverbPitchVoice latencyProbe; + BuiltInShimmerScheduling phaseOracle; + if (options.live) + { + latencyProbe.prepare(options.rate, 0); + phaseOracle.prepare(options.rate, latencyProbe.latencySamples(), latencyProbe.extraLatencySamples() * 3 / 4,latencyProbe.resetSamples()); + phaseOracle.configure(options.mode != 0); + // Preserve the intended interior-fade reversals when bounded + // history clearing adds its deterministic preparation interval. + for(const size_t index:{size_t{3},size_t{4},size_t{7}})events[index]+=latencyProbe.resetSamples(); + } + const std::array partitions{1, 17, 63, 257, 1021}; + for (int start = 0, part = 0; start < length; ++part) + { + if (options.routeAutomation) + { + if (start == events[0]) processor->creativeControls[0].store(2); + if (start == events[1]) processor->creativeControls[0].store(1); + if (start == events[2]) processor->creativeControls[0].store(0); + if (start == events[3]) { processor->shimmerPitchA.store(-12); processor->shimmerPitchB.store(19); } + if (start == events[4]) processor->shimmerVoiceEngine.store(0); + if (start == events[5]) processor->shimmerVoiceEngine.store(1); + if (start == events[6]) { processor->shimmerHold.store(1); processor->holdInputModes[5].store(0); } + if (start == events[7]) processor->shimmerScheduling.store(0); + if (start == events[8]) processor->shimmerScheduling.store(1); + if (start == events[9]) { processor->shimmerHold.store(0); processor->creativeControls[0].store(2); } + } + if (options.live) + { + // This case selects Shimmer throughout, so Additional advances + // this same policy once per sample. Check an interior blend + // before each intended reversal instead of assuming a fixed + // fade start from approximate millisecond latency. + if (start == events[3] || start == events[4] || start == events[7]) + { + const float phase = phaseOracle.mix({0, 0}, {1, 1})[0]; + capture.midFadePhaseProof = phase > 0 && phase < 1 && !phaseOracle.isSettled() + && phaseOracle.processOriginal() && phaseOracle.processSpread() && capture.midFadePhaseProof; + ++capture.midFadeReversals; + } + if (start == events[0] || start == events[2] || start == events[4] || start == events[7] || start == events[8]) processor->shimmerScheduling.store(1); + if (start == events[1] || start == events[3] || start == events[5]) processor->shimmerScheduling.store(0); + if (start == events[6]) { processor->shimmerHold.store(1); processor->holdInputModes[5].store(0); } + if (start == events[9]) processor->shimmerHold.store(0); + phaseOracle.configure(processor->shimmerScheduling.load() >= .5f); + } + int count = juce::jmin(options.irregular ? partitions[static_cast(part % 5)] : 128, length - start); + if (options.live || options.routeAutomation) for (int event : events) if (start < event) count = juce::jmin(count, event - start); + block.setSize(block.getNumChannels(), count, false, false, true); block.clear(); + for (int sample = 0; sample < count; ++sample) + { + const double time = static_cast(start + sample) / options.rate; + const float signal = .06f * static_cast( + std::sin(juce::MathConstants::twoPi * 223 * time) + + .37 * std::sin(juce::MathConstants::twoPi * 997 * time)); + for (int channel = 0; channel < options.channels; ++channel) + block.setSample(channel, sample, signal * (channel == 0 ? 1.0f : -.73f)); + } + processChecked(*processor, block, midi); + if (options.live) for (int sample = 0; sample < count; ++sample) phaseOracle.advance(); + for (int channel = 0; channel < options.channels; ++channel) + { + capture.audio.copyFrom(channel, start, block, channel, 0, count); + for (int sample = 0; sample < count; ++sample) + { + const float value = block.getSample(channel, sample); + capture.finite = std::isfinite(value) && capture.finite; + capture.peak = juce::jmax(capture.peak, std::abs(static_cast(value))); + } + } + start += count; + } + return capture; + }; + + auto source = std::make_unique(true), restored = std::make_unique(true); + auto embedded = std::make_unique(false); + bool state = source->shimmerScheduling.load() == 0 && embedded->shimmerScheduling.load() == 0; + state = setFreePluginParamForRegression(*source, "shimmerScheduling", 1) && state; + juce::MemoryBlock saved, repeated; source->getStateInformation(saved); + restored->setStateInformation(saved.getData(), static_cast(saved.getSize())); restored->getStateInformation(repeated); + state = saved == repeated && restored->shimmerScheduling.load() == 1 && state; + restored->selectAlgorithm(5); restored->selectSendMode(true); state = restored->shimmerScheduling.load() == 1 && state; + const auto parameters = describeFreePluginForRegression(*source)["parameters"]; + state = parameters.size() >= 1088 && parameters[1087]["id"].toString() == "shimmerScheduling" + && static_cast(parameters[1087]["defaultValue"]) == 0 && state; + auto old = juce::ValueTree::readFromData(saved.getData(), saved.getSize()); old.removeProperty("shimmerScheduling", nullptr); + const auto oldBytes = bytes(old); restored->setStateInformation(oldBytes.getData(), static_cast(oldBytes.getSize())); + state = restored->shimmerScheduling.load() == 0 && state; + old.setProperty("shimmerScheduling", std::numeric_limits::quiet_NaN(), nullptr); + const auto invalidBytes = bytes(old); restored->setStateInformation(invalidBytes.getData(), static_cast(invalidBytes.getSize())); + state = restored->shimmerScheduling.load() == 0 && state; + state = !setFreePluginParamForRegression(*embedded, "shimmerScheduling", 1) + && !setFreePluginNormalizedForRegression(*embedded, "shimmerScheduling", 1) && state; + auto* stateRow = new juce::DynamicObject(); stateRow->setProperty("kind", "state_migration_and_appended_descriptor"); add(stateRow, state); + + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + { + BuiltInSpreadReverbPitchVoice voice; voice.prepare(rate, 0); + const int extra = voice.extraLatencySamples(), warmup = voice.latencySamples(); + BuiltInShimmerScheduling policy; policy.prepare(rate, warmup); policy.configure(false); + bool policyPass = extra == static_cast(rate * .03); + const std::array original{.23f, -.37f}, spread{-.41f, .17f}; + for (int sample = 0; sample < 257; ++sample) + policyPass = policy.advance() == -1 && policy.processOriginal() && !policy.processSpread() + && policy.mix(original, spread) == original && policyPass; + for (bool target : {true, false}) + { + policy.configure(target); + int resets = 0, previousReset = -64; + for (int sample = 0; sample < warmup + static_cast(rate * .02) + 320; ++sample) + { + const int resetVoice = policy.advance(); + if (resetVoice >= 0) + { + policyPass = resetVoice == resets && sample - previousReset >= 64 + && policy.resetSpreadBank() == target && policyPass; + ++resets; previousReset = sample; + } + const auto mixed = policy.mix(original, spread); + policyPass = std::isfinite(mixed[0]) && std::isfinite(mixed[1]) && policyPass; + } + policyPass = resets == 4 && policy.isSettled() && policy.isSpread() == target + && policy.processSpread() == target && policy.processOriginal() != target && policyPass; + } + auto* policyRow = new juce::DynamicObject(); policyRow->setProperty("kind", "deterministic_policy_and_latency"); + policyRow->setProperty("sampleRate", rate); policyRow->setProperty("extraPitchSamples", extra); + policyRow->setProperty("warmupSamples", warmup); add(policyRow, policyPass); + + // Reverse twice while the actual helper mix is strictly inside the + // fade. No inactive-bank clear may interrupt either reversal; every + // step remains bounded by a full-scale 20 ms fade step. + const int fadeSamples = juce::jmax(1, static_cast(rate * .02)); + const double maximumStep = 1.0 / fadeSamples + 1e-6; + policy.configure(true); + float mix = 0; bool enteredFade = false, reversalPass = true; + for (int sample = 0; sample < warmup + fadeSamples + 320; ++sample) + { + policy.advance(); mix = policy.mix({0, 0}, {1, 1})[0]; + if (mix >= .25f && mix < .9f) { enteredFade = true; break; } + } + reversalPass = enteredFade && !policy.isSettled() && policy.processOriginal() && policy.processSpread(); + policy.configure(false); + for (int sample = 0; sample < juce::jmax(1, juce::roundToInt(rate * .004)); ++sample) + { + const float previous = mix; const int resetVoice = policy.advance(); mix = policy.mix({0, 0}, {1, 1})[0]; + reversalPass = resetVoice == -1 && mix > 0 && mix < 1 && mix <= previous + && static_cast(previous - mix) <= maximumStep + && policy.processOriginal() && policy.processSpread() && reversalPass; + } + policy.configure(true); + for (int sample = 0; sample < fadeSamples + 2; ++sample) + { + const float previous = mix; const int resetVoice = policy.advance(); mix = policy.mix({0, 0}, {1, 1})[0]; + reversalPass = resetVoice == -1 && std::isfinite(mix) && mix >= previous + && static_cast(mix - previous) <= maximumStep && reversalPass; + } + reversalPass = policy.isSettled() && policy.isSpread() && !policy.processOriginal() + && policy.processSpread() && mix == 1 && reversalPass; + auto* reversalRow = new juce::DynamicObject(); reversalRow->setProperty("kind", "actual_mid_fade_double_reversal_policy"); + reversalRow->setProperty("sampleRate", rate); reversalRow->setProperty("enteredInteriorFade", enteredFade); + reversalRow->setProperty("maximumAllowedStep", maximumStep); add(reversalRow, reversalPass); + } + // These setup-only checkpoints count live Debug CRT normal-block requested + // bytes, including the bank objects. Heap reservation reuse does not hide + // these allocations. JSON construction follows the measured interval. + juce::Array preparedMemoryDiagnostics; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + { +#if JUCE_WINDOWS && JUCE_DEBUG + const bool hookInactiveBefore = !ReverbCompatibilityHeapProbe::active; + const bool trackingEnabled = (_CrtSetDbgFlag(_CRTDBG_REPORT_FLAG) & _CRTDBG_ALLOC_MEM_DF) != 0; + _CrtMemState beforeOriginal{}, withOriginal{}, withBoth{}; + _CrtMemCheckpoint(&beforeOriginal); + auto originalBank = std::make_unique>(); + for (auto& voice : *originalBank) voice.prepare(rate); + _CrtMemCheckpoint(&withOriginal); + auto spreadBank = std::make_unique>(); + constexpr std::array rolePhases{0, 2, 1, 3}; + for (size_t role = 0; role < spreadBank->size(); ++role) (*spreadBank)[role].prepare(rate, rolePhases[role]); + _CrtMemCheckpoint(&withBoth); + const bool hookInactiveAfter = !ReverbCompatibilityHeapProbe::active; + const auto oldRequestedBytes = static_cast(withOriginal.lSizes[_NORMAL_BLOCK]) + - static_cast(beforeOriginal.lSizes[_NORMAL_BLOCK]); + const auto extraRequestedBytes = static_cast(withBoth.lSizes[_NORMAL_BLOCK]) + - static_cast(withOriginal.lSizes[_NORMAL_BLOCK]); + const auto extraNormalBlocks = static_cast(withBoth.lCounts[_NORMAL_BLOCK]) + - static_cast(withOriginal.lCounts[_NORMAL_BLOCK]); + const auto oldObjectBytes = static_cast(sizeof(*originalBank)); + const auto extraObjectBytes = static_cast(sizeof(*spreadBank)); + const bool validMeasurement = trackingEnabled && hookInactiveBefore && hookInactiveAfter + && oldRequestedBytes >= oldObjectBytes && extraRequestedBytes > extraObjectBytes && extraNormalBlocks > 0; +#endif + auto* memoryRow = new juce::DynamicObject(); + memoryRow->setProperty("kind", "off_callback_debug_crt_prepared_voice_storage"); + memoryRow->setProperty("sampleRate", rate); memoryRow->setProperty("voiceCountPerBank", 4); + memoryRow->setProperty("channelsPerVoice", 2); memoryRow->setProperty("status", "diagnostic_only"); + memoryRow->setProperty("originalVoiceObjectBytes", static_cast(sizeof(BuiltInReverbPitchVoice))); + memoryRow->setProperty("spreadVoiceObjectBytes", static_cast(sizeof(BuiltInSpreadReverbPitchVoice))); + memoryRow->setProperty("scope", "Off-callback live Debug CRT normal-block requested-byte deltas with four prepared Original voices retained while four Spread voices are prepared. Includes bank objects and their normal-block storage; excludes CRT metadata, allocator reservation/fragmentation, non-normal allocations and total process RSS. Debug STL/layout and other-thread allocations can affect the result; exact Release footprint is not asserted."); +#if JUCE_WINDOWS && JUCE_DEBUG + memoryRow->setProperty("measurement", "measured"); memoryRow->setProperty("trackingEnabled", trackingEnabled); + memoryRow->setProperty("callbackHeapHookInactive", hookInactiveBefore && hookInactiveAfter); + memoryRow->setProperty("normalBlockBytesBeforeOriginal", static_cast(beforeOriginal.lSizes[_NORMAL_BLOCK])); + memoryRow->setProperty("normalBlockBytesWithOriginal", static_cast(withOriginal.lSizes[_NORMAL_BLOCK])); + memoryRow->setProperty("normalBlockBytesWithBoth", static_cast(withBoth.lSizes[_NORMAL_BLOCK])); + memoryRow->setProperty("normalBlockCountWithOriginal", static_cast(withOriginal.lCounts[_NORMAL_BLOCK])); + memoryRow->setProperty("normalBlockCountWithBoth", static_cast(withBoth.lCounts[_NORMAL_BLOCK])); + memoryRow->setProperty("originalBankRequestedBytesDelta", oldRequestedBytes); + memoryRow->setProperty("extraSpreadBankRequestedBytesDelta", extraRequestedBytes); + memoryRow->setProperty("extraSpreadBankObjectBytes", extraObjectBytes); + memoryRow->setProperty("extraSpreadDynamicStorageBytesDiagnostic", extraRequestedBytes - extraObjectBytes); + memoryRow->setProperty("extraSpreadNormalBlocks", extraNormalBlocks); + add(memoryRow, validMeasurement); // Validity only, never an RSS/quality threshold. +#else + memoryRow->setProperty("measurement", "not_measured"); + memoryRow->setProperty("assertion", "not_asserted"); +#endif + preparedMemoryDiagnostics.add(juce::var(memoryRow)); + } + // The app's render setup calls prepare followed by explicit reset. Test + // the scheduling history independently of the wrapper's existing wet + // fade-in reset semantics, using the actual Additional engine. + double maximumSchedulingResetError = 0; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (int route : {0, 1, 2}) + { + auto prepared = std::make_unique(); + auto reset = std::make_unique(); + prepared->prepare(rate, 5); reset->prepare(rate, 5); + BuiltInAdditionalReverbs::Settings settings{2, .5f, .5f, .5f, 0, 20, 20000, 1, 1}; + settings.voiceMix = .5f; settings.shimmerRoute = static_cast(route); settings.spreadShimmer = true; + reset->configure(5, settings); + for (int sample = 0; sample < 4097; ++sample) reset->process(.013f, -.007f); + reset->reset(); reset->resetShimmerSchedulingHistory(); + prepared->configure(5, settings); reset->configure(5, settings); + double maximum = 0, peak = 0; bool finite = true; + for (int sample = 0; sample < juce::roundToInt(rate * .45); ++sample) + { + const float input = .04f * static_cast(std::sin(.047 * sample)); + const auto a = prepared->process(input, input * -.73f), b = reset->process(input, input * -.73f); + for (size_t channel = 0; channel < a.size(); ++channel) + { + maximum = juce::jmax(maximum, std::abs(static_cast(a[channel]) - b[channel])); + peak = juce::jmax(peak, std::abs(static_cast(b[channel]))); + finite = std::isfinite(a[channel]) && std::isfinite(b[channel]) && finite; + } + } + maximumSchedulingResetError = juce::jmax(maximumSchedulingResetError, maximum); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "additional_prepare_only_vs_dirtied_explicit_reset"); + row->setProperty("sampleRate", rate); row->setProperty("route", route); row->setProperty("maxError", maximum); + add(row, finite && peak > 1e-8 && maximum == 0 && reset->shimmerSchedulingSettled() && reset->shimmerSchedulingSpread()); + } + double maximumPartitionError = 0, maximumReprepareError = 0, maximumCopyError = 0, maximumDryError = 0; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (int channels : {1, 2}) + for (int route : {0, 1, 2}) + for (int mode : {0, 1}) + { + RenderOptions options; options.rate = rate; options.channels = channels; options.route = route; options.mode = mode; + const auto regular = render(options); options.irregular = true; options.preparedBlock = 512; + const auto irregular = render(options); const double partitionError = error(regular.audio, irregular.audio); + maximumPartitionError = juce::jmax(maximumPartitionError, partitionError); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "mono_stereo_routes_partition_and_offline_size"); + row->setProperty("sampleRate", rate); row->setProperty("channels", channels); row->setProperty("route", route); + row->setProperty("mode", mode); row->setProperty("maxError", partitionError); + add(row, regular.ready && irregular.ready && regular.finite && irregular.finite + && regular.peak > 1e-8 && irregular.peak <= 4 && partitionError < 1e-7); + } + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (int channels : {1, 2}) + for (int initial : {0, 1}) + { + RenderOptions options; options.rate = rate; options.channels = channels; options.mode = initial; options.route = 2; options.live = true; + const auto regular = render(options); options.irregular = true; options.preparedBlock = 512; + const auto irregular = render(options); const double partitionError = error(regular.audio, irregular.audio); + maximumPartitionError = juce::jmax(maximumPartitionError, partitionError); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "live_cancel_reverse_hold_and_partition"); + row->setProperty("sampleRate", rate); row->setProperty("channels", channels); row->setProperty("initialMode", initial); + row->setProperty("maxError", partitionError); + row->setProperty("midFadePhaseOracle", regular.midFadePhaseProof && irregular.midFadePhaseProof); + row->setProperty("regularMidFadeReversals", regular.midFadeReversals); + row->setProperty("irregularMidFadeReversals", irregular.midFadeReversals); + add(row, regular.ready && irregular.ready && regular.finite && irregular.finite && regular.peak > 1e-8 + && irregular.peak <= 4 && partitionError < 1e-7 && regular.midFadePhaseProof && irregular.midFadePhaseProof + && regular.midFadeReversals == 3 && irregular.midFadeReversals == 3); + } + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) for (int channels : {1, 2}) + { + RenderOptions options; options.rate = rate; options.channels = channels; options.mode = 1; + options.route = 1; options.routeAutomation = true; + const auto regular = render(options); options.irregular = true; options.preparedBlock = 512; + const auto irregular = render(options); const double partitionError = error(regular.audio, irregular.audio); + maximumPartitionError = juce::jmax(maximumPartitionError, partitionError); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "sleep_wake_route_pitch_engine_hold_scheduling_automation_partition"); + row->setProperty("sampleRate", rate); row->setProperty("channels", channels); row->setProperty("maxError", partitionError); + add(row, regular.ready && irregular.ready && regular.finite && irregular.finite && regular.peak > 1e-8 + && irregular.peak <= 4 && partitionError < 1e-7); + } + for (int mode : {0, 1}) + { + RenderOptions options; options.mode = mode; options.route = 2; + const auto original = render(options); options.reprepare = true; + const auto reprepared = render(options); const double reprepareError = error(original.audio, reprepared.audio); + maximumReprepareError = juce::jmax(maximumReprepareError, reprepareError); + options.reprepare = false; options.state = &saved; + const auto copied = render(options); const double copyError = error(original.audio, copied.audio); + maximumCopyError = juce::jmax(maximumCopyError, copyError); + options.state = nullptr; options.dryOnly = true; + const auto dryA = render(options); options.mode = 1 - mode; + const auto dryB = render(options); const double dryError = error(dryA.audio, dryB.audio); + maximumDryError = juce::jmax(maximumDryError, dryError); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "reprepare_copied_full_state_and_dry_unity"); + row->setProperty("mode", mode); row->setProperty("reprepareError", reprepareError); row->setProperty("copyError", copyError); + row->setProperty("dryError", dryError); add(row, original.finite && reprepared.finite && copied.finite + && reprepareError == 0 && copyError == 0 && dryError == 0); + } + double maximumHostResetError = 0; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (int mode : {0, 1}) + { + RenderOptions options; options.rate = rate; options.route = 2; options.mode = mode; options.explicitReset = true; + const auto freshReset = render(options); options.reprepare = true; + const auto dirtiedRepreparedReset = render(options); const double resetError = error(freshReset.audio, dirtiedRepreparedReset.audio); + maximumHostResetError = juce::jmax(maximumHostResetError, resetError); + auto* row = new juce::DynamicObject(); row->setProperty("kind", "native_explicit_reset_fresh_vs_dirtied_reprepare"); + row->setProperty("sampleRate", rate); row->setProperty("mode", mode); row->setProperty("maxError", resetError); + add(row, freshReset.ready && dirtiedRepreparedReset.ready && freshReset.finite && dirtiedRepreparedReset.finite + && freshReset.peak > 1e-8 && resetError == 0); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Opt-in deterministic Shimmer scheduling"); + result->setProperty("pass", passed && allocations == 0 && frees == 0); result->setProperty("cases", cases); + result->setProperty("maxPartitionError", maximumPartitionError); result->setProperty("maxReprepareError", maximumReprepareError); + result->setProperty("maxCopyError", maximumCopyError); result->setProperty("maxDryError", maximumDryError); + result->setProperty("maxSchedulingResetError", maximumSchedulingResetError); result->setProperty("maxHostResetError", maximumHostResetError); + result->setProperty("preparedMemoryDiagnostics", preparedMemoryDiagnostics); + result->setProperty("callbackHeapMeasured", heapMeasured); result->setProperty("callbackAllocations", allocations); + result->setProperty("callbackFrees", frees); result->setProperty("audioQuality", "not_asserted"); + result->setProperty("scope", "State, deterministic role phases, staged resets, warmup/fade, active-bank suspension, four-rate mono/stereo routing, prepare/copy/host-size parity and dry unity. Release callback heap measurement not asserted."); + return juce::var(result); +} + +inline juce::var profileShimmerSchedulingInstances() +{ + using namespace ShimmerSchedulingRegression; + const auto environment = [](const char* name, const char* fallback) { + return juce::SystemStats::getEnvironmentVariable(name, fallback); + }; + const double rate = juce::jlimit(44100.0, 192000.0, environment("OPENSTUDIO_SHIMMER_BENCH_RATE", "48000").getDoubleValue()); + const int frames = juce::jlimit(32, 512, environment("OPENSTUDIO_SHIMMER_BENCH_BLOCK", "128").getIntValue()); + const bool spreadOnly = environment("OPENSTUDIO_SHIMMER_BENCH_SPREAD_ONLY", "0") == "1"; +#if JUCE_DEBUG + constexpr int observations = 128; +#else + constexpr int observations = 2048; +#endif + const int warmup = juce::jmax(256, static_cast(rate * .65 / frames)); + bool finite = true, stateCopies = true, transitionsSettled = true; + juce::Array cases; + for (int route : {0, 1, 2}) for (int instances : {1, 4, 8}) + for (int mode : {0, 1}) for (bool identical : {false, true}) + { + if (spreadOnly && mode == 0) continue; + auto preset = std::make_unique(true); preset->selectAlgorithm(5); + preset->shimmerScheduling.store(static_cast(mode)); preset->creativeControls[0].store(static_cast(route)); + preset->shimmerAmount.store(1); preset->shimmerVoiceMix.store(.5f); preset->wetLevel.store(1); preset->dryLevel.store(0); + juce::MemoryBlock state; preset->getStateInformation(state); + std::vector> processors; + std::vector> blocks; + processors.reserve(static_cast(instances)); blocks.reserve(static_cast(instances)); + double prepareMs = 0; + for (int index = 0; index < instances; ++index) + { + auto processor = std::make_unique(true); + processor->setStateInformation(state.getData(), static_cast(state.getSize())); + juce::MemoryBlock repeated; processor->getStateInformation(repeated); stateCopies = state == repeated && stateCopies; + const auto begin = juce::Time::getHighResolutionTicks(); processor->prepareToPlay(rate, frames); + prepareMs += juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - begin) * 1000; + blocks.emplace_back(processor->getTotalNumOutputChannels(), frames); processors.push_back(std::move(processor)); + } + juce::MidiBuffer midi; + std::vector timings; timings.reserve(observations); + double totalUs = 0; int overruns = 0; + for (int iteration = -warmup; iteration < observations; ++iteration) + { + for (int index = 0; index < instances; ++index) + { + auto& block = blocks[static_cast(index)]; block.clear(); + for (int sample = 0; sample < frames; ++sample) + { + const double phase = .047 * ((iteration + warmup) * frames + sample + (identical ? 0 : index * 137)); + const float value = .04f * static_cast(std::sin(phase) + .27 * std::sin(phase * 2.37)); + block.setSample(0, sample, value); block.setSample(1, sample, value * .8f); + } + } + const auto begin = juce::Time::getHighResolutionTicks(); + for (int index = 0; index < instances; ++index) + processors[static_cast(index)]->processBlock(blocks[static_cast(index)], midi); + const double elapsed = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - begin) * 1e6; + if (iteration >= 0) { timings.push_back(elapsed); totalUs += elapsed; overruns += elapsed > frames / rate * 1e6 ? 1 : 0; } + for (const auto& block : blocks) for (int channel = 0; channel < block.getNumChannels(); ++channel) + for (int sample = 0; sample < frames; ++sample) finite = std::isfinite(block.getSample(channel, sample)) && finite; + } + std::sort(timings.begin(), timings.end()); + auto* row = new juce::DynamicObject(); row->setProperty("route", route); row->setProperty("instances", instances); + row->setProperty("scheduling", mode == 0 ? "Original" : "Spread"); + row->setProperty("copiedFullState", true); row->setProperty("identicalInput", identical); + row->setProperty("prepareMsTotal", prepareMs); row->setProperty("averageUs", totalUs / observations); + row->setProperty("medianUs", timings[timings.size() / 2]); row->setProperty("p95Us", timings[timings.size() * 95 / 100]); + row->setProperty("p99Us", timings[timings.size() * 99 / 100]); row->setProperty("maxUs", timings.back()); + row->setProperty("budgetUs", frames / rate * 1e6); row->setProperty("deadlineOverruns", overruns); cases.add(juce::var(row)); + } + // Live switches include the staged clear, dual-bank warmup and fade. These + // timing rows deliberately do not assert a deadline while Original is live. + juce::Array transitions; + for (double sampleRate : {48000.0, 192000.0}) for (int instances : {1, 8}) for (int initialMode : {0, 1}) + { + if (spreadOnly) continue; + std::vector> processors; + std::vector> blocks; + for (int index = 0; index < instances; ++index) + { + auto processor = std::make_unique(true); processor->selectAlgorithm(5); + processor->shimmerScheduling.store(static_cast(initialMode)); processor->creativeControls[0].store(2); + processor->shimmerAmount.store(1); processor->shimmerVoiceMix.store(.5f); processor->wetLevel.store(1); processor->dryLevel.store(0); + processor->prepareToPlay(sampleRate, frames); + blocks.emplace_back(processor->getTotalNumOutputChannels(), frames); processors.push_back(std::move(processor)); + } + const int settleBlocks = static_cast(sampleRate * .35) / frames + 1; + BuiltInSpreadReverbPitchVoice timingProbe; + timingProbe.prepare(sampleRate, 3); + // Observe every reset, prime, warmup and fade phase plus a settled + // margin. A fixed 230 ms window ended before some bounded clears did. + const int liveSamples = 192 + timingProbe.resetSamples() + timingProbe.latencySamples() + + (initialMode == 0 ? timingProbe.primingSamples() : 0) + + juce::roundToInt(sampleRate * .07); + const int liveBlocks = (liveSamples + frames - 1) / frames; + juce::MidiBuffer midi; std::vector timings; timings.reserve(static_cast(liveBlocks)); + double totalUs = 0; int overruns = 0; + for (int iteration = -settleBlocks; iteration < liveBlocks; ++iteration) + { + if (iteration == 0) for (auto& processor : processors) processor->shimmerScheduling.store(initialMode == 0 ? 1.0f : 0.0f); + for (auto& block : blocks) + { + block.clear(); + for (int sample = 0; sample < frames; ++sample) + { + const float value = .04f * static_cast(std::sin(.047 * ((iteration + settleBlocks) * frames + sample))); + block.setSample(0, sample, value); block.setSample(1, sample, value * .8f); + } + } + const auto begin = juce::Time::getHighResolutionTicks(); + for (int index = 0; index < instances; ++index) processors[static_cast(index)]->processBlock(blocks[static_cast(index)], midi); + const double elapsed = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - begin) * 1e6; + if (iteration >= 0) { timings.push_back(elapsed); totalUs += elapsed; overruns += elapsed > frames / sampleRate * 1e6 ? 1 : 0; } + for (const auto& block : blocks) for (int channel = 0; channel < block.getNumChannels(); ++channel) + for (int sample = 0; sample < frames; ++sample) finite = std::isfinite(block.getSample(channel, sample)) && finite; + } + bool settled = true; + for (const auto& processor : processors) + settled = OpenStudioReverbCompatibilityRegressionAccess::shimmerSettled(*processor) + && OpenStudioReverbCompatibilityRegressionAccess::shimmerSpread(*processor) == (initialMode == 0) && settled; + transitionsSettled = transitionsSettled && settled; + std::sort(timings.begin(), timings.end()); + auto* row = new juce::DynamicObject(); row->setProperty("sampleRate", sampleRate); row->setProperty("instances", instances); + row->setProperty("route", 2); row->setProperty("identicalInput", true); row->setProperty("initialMode", initialMode); + row->setProperty("observedSamples", liveBlocks * frames); row->setProperty("settledAtEnd", settled); + row->setProperty("averageUs", totalUs / liveBlocks); row->setProperty("p95Us", timings[timings.size() * 95 / 100]); + row->setProperty("p99Us", timings[timings.size() * 99 / 100]); row->setProperty("maxUs", timings.back()); + row->setProperty("budgetUs", frames / sampleRate * 1e6); row->setProperty("deadlineOverruns", overruns); transitions.add(juce::var(row)); + } + // Extra bank preparation/memory and reset work are separate from warmed + // callback timings. Private-byte deltas are approximate allocator readings. + juce::Array preparation; + for (double sampleRate : {48000.0, 192000.0}) + { + if (spreadOnly) continue; + auto original = std::make_unique>(); + const auto oldBegin = juce::Time::getHighResolutionTicks(); + for (auto& voice : *original) voice.prepare(sampleRate); + const double originalMs = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - oldBegin) * 1000; + const auto before = privateBytes(); + auto spread = std::make_unique>(); + const auto newBegin = juce::Time::getHighResolutionTicks(); + constexpr std::array rolePhases{0, 2, 1, 3}; + for (size_t role = 0; role < spread->size(); ++role) (*spread)[role].prepare(sampleRate, rolePhases[role]); + const double extraMs = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - newBegin) * 1000; + const auto after = privateBytes(); + double worstResetUs = 0; + for (auto& voice : *spread) for (int iteration = 0; iteration < 32; ++iteration) + { + const auto begin = juce::Time::getHighResolutionTicks(); voice.reset(); + worstResetUs = juce::jmax(worstResetUs, + juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - begin) * 1e6); + } + double worstFourVoiceResetUs = 0; + for (int iteration = 0; iteration < 32; ++iteration) + { + const auto begin = juce::Time::getHighResolutionTicks(); + for (auto& voice : *spread) voice.reset(); + worstFourVoiceResetUs = juce::jmax(worstFourVoiceResetUs, + juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - begin) * 1e6); + } + auto* row = new juce::DynamicObject(); row->setProperty("sampleRate", sampleRate); + row->setProperty("fourOriginalVoicesPrepareMs", originalMs); row->setProperty("fourExtraSpreadVoicesPrepareMs", extraMs); + row->setProperty("extraSpreadPrivateBytesDeltaApprox", + static_cast(before >= 0 && after >= before ? after - before : -1)); + row->setProperty("worstSingleSpreadVoiceResetUs", worstResetUs); + row->setProperty("worstFourSpreadVoiceHistoryResetUs", worstFourVoiceResetUs); preparation.add(juce::var(row)); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Whole-wrapper synchronized Shimmer scheduling cost"); + result->setProperty("pass", finite && stateCopies && transitionsSettled); result->setProperty("finiteOutput", finite); result->setProperty("fullStateCopiesExact", stateCopies); + result->setProperty("allLiveTransitionsSettled", transitionsSettled); + result->setProperty("cases", cases); result->setProperty("liveTransitions", transitions); + result->setProperty("preparation", preparation); result->setProperty("observations", observations); + result->setProperty("warmupBlocks", warmup); result->setProperty("spreadOnly", spreadOnly); + result->setProperty("sampleRate", rate); result->setProperty("blockSize", frames); result->setProperty("cpu", juce::SystemStats::getCpuModel()); + result->setProperty("timing", "diagnostic_only"); result->setProperty("deviceDropouts", "not_asserted"); result->setProperty("audioQuality", "not_asserted"); + result->setProperty("scope", "Serial whole-plugin callbacks, 1/4/8 synchronized full-state copies, Tank/Input/Both, identical and varied inputs, warm steady state. Timing and approximate memory are diagnostics; host graph/device scheduling and live-switch deadline guarantees are not asserted."); + return juce::var(result); +} diff --git a/Source/FreePluginSignalRepairRegression.h b/Source/FreePluginSignalRepairRegression.h new file mode 100644 index 0000000..ac8ca86 --- /dev/null +++ b/Source/FreePluginSignalRepairRegression.h @@ -0,0 +1,270 @@ +#pragma once + +inline juce::var checkFreeSignalCompatibility() +{ + bool pass = true; juce::Array cases; + for (const auto& entry : freeSuiteFactories()) + { + const juce::String name = entry.first; + if (name == "Reverb" || name == "Delay" || name == "Compressor" || name == "Gate" || name == "Limiter" + || name == "Graphic EQ" || name == "Gain Phase") continue; + auto p = entry.second(); + const auto selected = [](juce::AudioProcessor& processor) + { + const auto schema = describeFreePluginForRegression(processor); + if (const auto* parameters = schema["parameters"].getArray()) + for (const auto& parameter : *parameters) + if (parameter["id"].toString() == "signalPath") return static_cast(parameter["value"]); + return -1; + }; + bool okay = selected(*p) == 1; + juce::MemoryBlock modern; p->getStateInformation(modern); + auto tree = juce::ValueTree::readFromData(modern.getData(), modern.getSize()); + tree.removeProperty("signalPath", nullptr); + juce::MemoryBlock old; { juce::MemoryOutputStream stream(old, false); tree.writeToStream(stream); } + p->setStateInformation(old.getData(), static_cast(old.getSize())); + okay = okay && selected(*p) == 0; + juce::MemoryBlock legacy; p->getStateInformation(legacy); + // All four containers carry the full plugin bytes. Exercise the native + // restoration entry point after portable base64 encode/decode as well + // as direct old-project recall; alternate snapshots model Compare A/B. + for (int iteration = 0; iteration < 4; ++iteration) + { + const auto& expected = iteration % 2 == 0 ? modern : legacy; + juce::MemoryBlock decoded; okay = decoded.fromBase64Encoding(expected.toBase64Encoding()) && okay; + okay = restoreFreePluginStateForRegression(*p, decoded) && okay; + juce::MemoryBlock actual; p->getStateInformation(actual); + const auto expectedTree = juce::ValueTree::readFromData(expected.getData(), expected.getSize()); + const auto actualTree = juce::ValueTree::readFromData(actual.getData(), actual.getSize()); + okay = expectedTree.isEquivalentTo(actualTree) && selected(*p) == (iteration % 2 == 0 ? 1 : 0) && okay; + } + auto* row = new juce::DynamicObject(); row->setProperty("plugin", name); row->setProperty("pass", okay); + cases.add(row); pass = pass && okay; + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Signal path old-state and complete snapshot compatibility"); + result->setProperty("cases", cases); result->setProperty("pass", pass && cases.size() == 9); return result; +} + +inline juce::var checkFreePitchRangeRepair() +{ + bool pass = true; juce::Array rows; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + for (double frequency : {40.0, 55.0, 82.406889, 110.0, 220.0, 440.0, 997.0}) + { + PitchDetector detector; detector.setRateNormalized(true); + detector.setMinFrequency(40); detector.setMaxFrequency(1000); detector.prepare(rate, 127); + std::array input {}; + for (int position = 0; position < static_cast(rate * .3); position += 127) + { + for (int i = 0; i < 127; ++i) input[static_cast(i)] = static_cast(.2 * std::sin(juce::MathConstants::twoPi * frequency * (position + i) / rate)); + detector.processSamples(input.data(), 127); + } + const double actual = detector.getDetectedFrequency(); + const double cents = actual > 0 ? 1200 * std::log2(actual / frequency) : 9999; + const bool okay = std::abs(cents) < 5 && detector.getConfidence() > .95f; + auto* row = new juce::DynamicObject(); row->setProperty("rate", rate); row->setProperty("hz", frequency); + row->setProperty("centsError", cents); row->setProperty("pass", okay); rows.add(row); pass = pass && okay; + } + // Stop-band signals which would alias into the advertised tracking band + // without the decimator filter must remain below the detector silence gate. + bool aliasPass = true; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + { + PitchDetector detector; detector.setRateNormalized(true); detector.setMinFrequency(40); detector.setMaxFrequency(1000); detector.prepare(rate, 127); + const double analysisRate = rate / std::ceil(rate / 32000.0); + const double frequency = analysisRate - 440; + if (frequency >= rate * .5) continue; + std::array input {}; + for (int position = 0; position < static_cast(rate * .3); position += 127) + { + for (int i = 0; i < 127; ++i) input[static_cast(i)] = static_cast(.9 * std::sin(juce::MathConstants::twoPi * frequency * (position + i) / rate)); + detector.processSamples(input.data(), 127); + } + aliasPass = aliasPass && detector.getDetectedFrequency() == 0; + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Corrected pitch range and anti-alias rejection"); + result->setProperty("cases", rows); result->setProperty("aliasRejection", aliasPass); result->setProperty("pass", pass && aliasPass); + result->setProperty("audioQuality", "not_asserted"); return result; +} + +inline juce::var checkFreeSignalTransitions() +{ + bool pass = true; juce::Array rows; + // Repeated/reversed changes have identical event times at every partition. + for (int oversampling : {0, 1, 2}) + { + const auto render = [oversampling](int partition) + { + OpenStudioSaturator p; p.drive.store(18); p.mix.store(1); p.setOversamplingMode(static_cast(oversampling)); + p.prepareToPlay(48000, partition); juce::MidiBuffer midi; + juce::AudioBuffer block(2, partition); std::vector result(12000); + const std::array times {2400, 2500, 2700, 5000, 5700, 12000}; + const std::array types {3, 0, 7, 6, 8}; size_t event = 0; + for (int start = 0; start < 12000;) + { + if (start == times[event]) { p.satType.store(types[event]); ++event; } + const int count = juce::jmin(partition, times[event] - start); + block.setSize(2, count, false, false, true); + for (int i = 0; i < count; ++i) for (int ch = 0; ch < 2; ++ch) + block.setSample(ch, i, .2f * std::sin(.13f * static_cast(start + i))); + p.processBlock(block, midi); + for (int i = 0; i < count; ++i) result[static_cast(start + i)] = block.getSample(0, i); + start += count; + } + return result; + }; + const auto reference = render(1); + for (int partition : {32, 64, 127, 512, 1024}) + { + const auto actual = render(partition); double error = 0; bool finite = true; + for (size_t i = 0; i < actual.size(); ++i) { finite = finite && std::isfinite(actual[i]); error = juce::jmax(error, std::abs(static_cast(actual[i] - reference[i]))); } + const bool okay = finite && error < 2e-5; pass = pass && okay; + auto* row = new juce::DynamicObject(); row->setProperty("oversampling", 1 << oversampling); row->setProperty("partition", partition); row->setProperty("maxError", error); row->setProperty("pass", okay); rows.add(row); + } + } + juce::Array bypassRows; + for (int channels : {1, 2}) for (int character : {0, 1}) for (double rate : {44100.0, 192000.0}) + { + OpenStudioUtilityEffect p(OpenStudioUtilityEffect::Kind::Preamp); + p.setControl("audioCharacter", static_cast(character)); p.setControl("bypass", 1); p.prepareToPlay(rate, 32); + const int latency = p.getLatencySamples(); juce::AudioBuffer block(channels, 1024); juce::MidiBuffer midi; + std::array input {}; double error = 0; bool finite = true; + for (size_t i = 0; i < input.size(); ++i) input[i] = .2f * std::sin(static_cast(i) * .27f) + (i == 117 ? .4f : 0); + for (int start = 0; start < 4096; start += 1024) + { + for (int ch = 0; ch < channels; ++ch) for (int i = 0; i < 1024; ++i) block.setSample(ch, i, input[static_cast(start+i)]); + p.processBlock(block, midi); + for (int ch = 0; ch < channels; ++ch) for (int i = 0; i < 1024; ++i) + { + const float expected = start + i < latency ? 0 : input[static_cast(start + i - latency)]; + finite = finite && std::isfinite(block.getSample(ch,i)); error = juce::jmax(error, std::abs(static_cast(block.getSample(ch,i)-expected))); + } + } + const bool okay = finite && error == 0; pass = pass && okay; + auto* row = new juce::DynamicObject(); row->setProperty("channels", channels); row->setProperty("character", character); row->setProperty("rate", rate); row->setProperty("maxError", error); row->setProperty("pass", okay); bypassRows.add(row); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Corrected signal transitions and Preamp oversize bypass"); + result->setProperty("saturator", rows); result->setProperty("preamp", bypassRows); result->setProperty("pass", pass); return result; +} + +inline juce::var checkDrumReleaseRepair() +{ + bool pass = true; juce::Array rows; + for (int engine : {0, 1}) for (int note : {46, 49, 51}) for (int output = 0; output <= 8; ++output) + { + const auto render = [engine, note, output](int controller) + { + auto p = std::make_unique(); p->articulationEngine.store(static_cast(engine)); + for (auto& destination : p->pieceOutput) destination.store(static_cast(output)); + p->prepareToPlay(48000, 127); juce::AudioBuffer block(18, 127), result(18, 96000); juce::MidiBuffer events, midi; + events.addEvent(juce::MidiMessage::noteOn(10,note,1.0f),0); + if (controller >= 0) events.addEvent(juce::MidiMessage::controllerEvent(10,controller,0),24000); + for (int start=0;start<96000;start+=127) + { + const int count=juce::jmin(127,96000-start); block.setSize(18,count,false,false,true); block.clear();midi.clear();midi.addEvents(events,start,count,-start); + p->processBlock(block,midi);for(int ch=0;ch<18;++ch)result.copyFrom(ch,start,block,ch,0,count); + } + return result; + }; + const auto original=render(-1),released=render(123),panic=render(120);double error=0; + for(int ch=0;ch<18;++ch)for(int i=0;i<96000;++i)error=juce::jmax(error,std::abs(static_cast(original.getSample(ch,i)-released.getSample(ch,i)))); + const bool okay=error==0 && released.getMagnitude(24000,12000)>1e-5f && panic.getMagnitude(24000,72000)==0; + auto* row=new juce::DynamicObject();row->setProperty("engine",engine);row->setProperty("note",note);row->setProperty("outputPair",output);row->setProperty("CC123Null",error);row->setProperty("pass",okay);rows.add(row);pass=pass&&okay; + } + auto* result=new juce::DynamicObject();result->setProperty("plugin","Drum CC123 decay and CC120 panic");result->setProperty("cases",rows);result->setProperty("pass",pass);return result; +} + +inline juce::var profileSignalPitchCost() +{ + juce::Array rows; bool finite = true; + for (int mode : {0, 1}) for (double rate : {48000.0, 96000.0, 192000.0}) for (int block : {32, 64, 128}) for (int count : {1, 4}) + { + std::vector> processors; + std::vector> buffers; + for (int i=0;i();p->signalPath.store(static_cast(mode));p->minFreqParam.store(40);p->maxFreqParam.store(1000); + p->setRateAndBufferSizeDetails(rate,block);p->prepareToPlay(rate,block);processors.push_back(std::move(p));buffers.emplace_back(2,block); + } + juce::MidiBuffer midi;midi.ensureSize(1024);std::vector times;times.reserve(2048);int overruns=0; + for (int frame=-512;frame<2048;++frame) + { + midi.clear();for(auto& audio:buffers)for(int i=0;i(.15*std::sin(juce::MathConstants::twoPi*82.406889*((frame+512)*block+i)/rate))); + const auto start=juce::Time::getHighResolutionTicks();for(int i=0;i(i)]->processBlock(buffers[static_cast(i)],midi); + const double us=juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks()-start)*1e6; + if(frame>=0){times.push_back(us);if(us>block/rate*1e6)++overruns;} + for(const auto& audio:buffers)for(int ch=0;ch<2;++ch)for(int i=0;isetProperty("signalPath",mode);row->setProperty("rate",rate);row->setProperty("block",block);row->setProperty("instances",count); + row->setProperty("medianUs",times[times.size()/2]);row->setProperty("p99Us",times[times.size()*99/100]);row->setProperty("maxUs",times.back());row->setProperty("deadlineUs",block/rate*1e6);row->setProperty("overruns",overruns);rows.add(row); + } + auto* result=new juce::DynamicObject();result->setProperty("plugin","Pitch signal repair deadline diagnostics");result->setProperty("cases",rows);result->setProperty("pass",finite); + result->setProperty("status","diagnostic_only; sequential warmed in-process CPU; excludes audio-device scheduling");return result; +} + +inline juce::var checkSignalPitchFFTParity() +{ + bool pass=true;juce::Array rows; + for(double rate:{44100.0,48000.0,96000.0,192000.0})for(int block:{32,127,512}) + { + signalsmith::stretch::SignalsmithStretch oldEngine; + signalsmith::stretch::SignalsmithStretch newEngine; + const auto setup=[&](auto& engine){engine.presetCheaper(2,static_cast(rate));engine.setTransposeFactor(1.25f);engine.setFormantBase(static_cast(220/rate));engine.setFormantSemitones(2,true);};setup(oldEngine);setup(newEngine); + juce::AudioBuffer input(2,block),a(2,block),b(2,block);std::array in{input.getReadPointer(0),input.getReadPointer(1)};std::array outA{a.getWritePointer(0),a.getWritePointer(1)},outB{b.getWritePointer(0),b.getWritePointer(1)}; + double error=0,energy=0;bool finite=true; + for(int start=0;start(rate*.4);start+=block) + { + for(int ch=0;ch<2;++ch)for(int i=0;i(.2*std::sin(juce::MathConstants::twoPi*(220+ch*110)*(start+i)/rate)+.03*std::sin(.172*(start+i)))); + oldEngine.process(in,block,outA,block);newEngine.process(in,block,outB,block); + for(int ch=0;ch<2;++ch)for(int i=0;i.01&&error<1e-5&&oldEngine.inputLatency()==newEngine.inputLatency()&&oldEngine.outputLatency()==newEngine.outputLatency();pass=pass&&okay; + auto* row=new juce::DynamicObject();row->setProperty("rate",rate);row->setProperty("block",block);row->setProperty("maximumError",error);row->setProperty("pass",okay);rows.add(row); + } + auto* result=new juce::DynamicObject();result->setProperty("plugin","Pitch Correct opt-in fine FFT scheduling parity");result->setProperty("cases",rows);result->setProperty("pass",pass);return result; +} + + +inline juce::var checkSignalPoleAndMIDICompatibility() +{ + bool polePass = true; double maximumRelativeTimeError = 0; + for (double rate : {44100.0, 48000.0, 96000.0, 192000.0}) + { + BuiltInRatePole pole; pole.prepare(rate); + polePass = polePass && pole.map(0) == 0 && pole.map(1) == 1; + for (float coefficient : {.018f, .033f, .12f, .37f, .71f, .98f}) + { + // Compare physical e-fold decay time against the 48 kHz pole, + // independently of sample count and the lookup-table construction. + const double measuredSeconds = -1 / (rate * std::log1p(-pole.map(coefficient))); + const double expectedSeconds = -1 / (48000 * std::log1p(-static_cast(coefficient))); + maximumRelativeTimeError = juce::jmax(maximumRelativeTimeError, std::abs(measuredSeconds / expectedSeconds - 1)); + polePass = polePass && std::isfinite(measuredSeconds) && std::abs(measuredSeconds / expectedSeconds - 1) < .0005; + } + } + bool midiPass = true; + OpenStudioEQ source(true); + auto* capture = new juce::DynamicObject(); capture->setProperty("action", "capture"); capture->setProperty("bank", 0); capture->setProperty("program", 3); capture->setProperty("name", "Signal recall"); + midiPass = source.editMIDIProgram(capture) && midiPass; + auto* configure = new juce::DynamicObject(); configure->setProperty("action", "configure"); configure->setProperty("enabled", true); configure->setProperty("channel", 1); + midiPass = source.editMIDIProgram(configure) && midiPass; + juce::MemoryBlock saved; source.getStateInformation(saved); + for (int mode : {0, 1}) + { + auto tree = juce::ValueTree::readFromData(saved.getData(), saved.getSize()); + auto slot = tree.getChildWithName("MIDIPrograms").getChild(0); + midiPass = midiPass && slot.getProperty("configuration").size() == 7; + if (mode == 0) { tree.removeProperty("signalPath", nullptr); slot.removeProperty("signalPath", nullptr); } + juce::MemoryBlock encoded; { juce::MemoryOutputStream stream(encoded, false); tree.writeToStream(stream); } + OpenStudioEQ restored(true); restored.setStateInformation(encoded.getData(), static_cast(encoded.getSize())); + // Configuration changes prepare their alternate MIDI engines off-thread. + restored.signalPath.store(static_cast(1 - mode)); + restored.setRateAndBufferSizeDetails(48000, 64); restored.prepareToPlay(48000, 64); + juce::AudioBuffer audio(2, 64); audio.clear(); juce::MidiBuffer midi; + midi.addEvent(juce::MidiMessage::programChange(1, 3), 17); restored.processBlock(audio, midi); + midiPass = midiPass && !static_cast(restored.midiProgramInfo()["restoreRejected"]) && restored.signalPath.load() == static_cast(mode); + } + auto* result = new juce::DynamicObject(); result->setProperty("plugin", "Physical pole-time and EQ MIDI signal-path compatibility"); + result->setProperty("polePass", polePass); result->setProperty("maximumRelativeTimeError", maximumRelativeTimeError); + result->setProperty("midiPass", midiPass); result->setProperty("pass", polePass && midiPass); return result; +} diff --git a/Source/FreePluginStressPerformance.h b/Source/FreePluginStressPerformance.h index c3fdc21..1414c21 100644 --- a/Source/FreePluginStressPerformance.h +++ b/Source/FreePluginStressPerformance.h @@ -9,10 +9,10 @@ inline juce::var measureFreeSuiteStress() {"EQ Linear high resolution + spectral",[]{auto p=std::make_unique(true);p->phaseMode.store(1);p->phaseQuality.store(2);p->spectralProcessing.store(1);for(int band=0;band<24;++band){p->bands[band].enabled.store(1);p->bands[band].gain.store(band%2?3.0f:-3.0f);}return p;}}, {"Limiter 4x",[]{auto p=std::make_unique(true);p->oversampleQuality.store(2);return p;}}, {"Limiter 16x",[]{auto p=std::make_unique(true);p->oversampleQuality.store(4);return p;}}, - {"Synth 32 notes",[]{return std::make_unique();}}, - {"Piano 32 notes + coupled body",[]{auto p=std::make_unique();p->coupledBody.store(.5f);return p;}}, - {"Guitar 32 notes",[]{return std::make_unique();}}, - {"Drums 32 notes + separate outputs",[]{auto p=std::make_unique();for(size_t piece=0;piece<8;++piece)p->pieceOutput[piece].store(static_cast(piece+1));return p;}}, + {"Synth 32 voices on two channels",[]{return std::make_unique();}}, + {"Piano 32 note-ons on two channels + coupled body",[]{auto p=std::make_unique();p->coupledBody.store(.5f);return p;}}, + {"Guitar 32 note-ons on two channels",[]{return std::make_unique();}}, + {"Drums 32 note-ons on two channels + separate outputs",[]{auto p=std::make_unique();for(size_t piece=0;piece<8;++piece)p->pieceOutput[piece].store(static_cast(piece+1));return p;}}, {"Reverb Room to Shimmer switch with retained tail",[]{auto p=std::make_unique(true);p->tailSpillover.store(1);p->decayTime.store(2);return p;}}, {"Reverb 4-path IR / four outputs",[]{auto p=std::make_unique(true);juce::AudioBuffer ir(4,96000);ir.clear();for(int ch=0;ch<4;++ch)for(int i=ch;i<96000;i+=127)ir.setSample(ch,i,static_cast(.03*std::exp(-i/20000.0)));const auto file=juce::File::getCurrentWorkingDirectory().getChildFile("output/performance-four-path-ir.wav");writeProbeWave(file,ir,48000);p->convolutionSpace.loadFile(file);auto* shape=new juce::DynamicObject();shape->setProperty("outputLayout",1);p->convolutionSpace.edit(juce::var(shape));p->algorithm.store(7);return p;}} }; @@ -31,6 +31,15 @@ inline juce::var measureFreeSuiteStress() const double prepareMs=juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks()-prepareStart)*1000;const auto bytesPrepared=privateBytes(); const int iterations=name.startsWith("Eight parallel")?11250:1024; std::vector times;times.reserve(static_cast(iterations));int overruns=0;double peak=0; + std::vector minimumVoices(processors.size(), std::numeric_limits::max()), maximumVoices(processors.size(), -1); + const auto observedVoices=[](juce::AudioProcessor& processor) + { + if (auto* p=dynamic_cast(&processor)) return p->getRenderingVoiceCountForRegression(); + if (auto* p=dynamic_cast(&processor)) return p->getRenderingVoiceCountForRegression(); + if (auto* p=dynamic_cast(&processor)) return p->getRenderingVoiceCountForRegression(); + if (auto* p=dynamic_cast(&processor)) return p->getRenderingVoiceCountForRegression(); + return -1; + }; for(int frame=-128;frame((frame+128)*block+sample));buffer.setSample(0,sample,value);buffer.setSample(1,sample,value);} - if(tailOnly&&frame==0)if(auto* reverb=dynamic_cast(processors[i].get()))reverb->selectAlgorithm(4); + if(tailOnly&&frame==0)if(auto* reverb=dynamic_cast(processors[i].get()))reverb->selectAlgorithm(static_cast(OpenStudioReverb::Algorithm::Shimmer)); if(auto* eq=dynamic_cast(processors[i].get());eq&&eq->hasPreparedMIDIPrograms()&&(frame+128)%64==0)midi[i].addEvent(juce::MidiMessage::programChange(1,((frame+128)/64)%32),19); - if(processors[i]->acceptsMidi()&&(frame+128)%128==0){midi[i].addEvent(juce::MidiMessage::allSoundOff(1),0);for(int note=0;noteacceptsMidi()&&(frame+128)%128==0) + { + const int channels=(notes+15)/16; + for(int channel=1;channel<=channels;++channel)midi[i].addEvent(juce::MidiMessage::allSoundOff(channel),0); + for(int note=0;noteprocessBlock(audio[i],midi[i]); const double elapsed=juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks()-started)*1e6; if(frame>=0){times.push_back(elapsed);if(elapsed>block/rate*1e6)++overruns;} + if(frame>=0)for(size_t i=0;i=0){minimumVoices[i]=juce::jmin(minimumVoices[i],voices);maximumVoices[i]=juce::jmax(maximumVoices[i],voices);} + } for(const auto& buffer:audio)for(int ch=0;ch(value)));} } std::sort(times.begin(),times.end());auto* row=new juce::DynamicObject();row->setProperty("case",name);row->setProperty("rate",rate);row->setProperty("block",block);row->setProperty("instances",static_cast(processors.size()));row->setProperty("notesPerInstrument",notes); + juce::Array voiceCounts; + for(size_t i=0;i=0) + { + auto* voice=new juce::DynamicObject();voice->setProperty("processor",processors[i]->getName()); + voice->setProperty("minimumRenderingVoices",minimumVoices[i]);voice->setProperty("maximumRenderingVoices",maximumVoices[i]); + voice->setProperty("noteOnRequests",notes);voice->setProperty("midiChannels",(notes+15)/16);voiceCounts.add(voice); + if(dynamic_cast(processors[i].get()))finite=finite&&maximumVoices[i]==notes; + } + row->setProperty("observedVoiceCounts",voiceCounts); row->setProperty("iterations",iterations);row->setProperty("audioSeconds",iterations*block/rate);row->setProperty("prepareMs",prepareMs);row->setProperty("incrementalPreparedPrivateBytes",bytesPrepared-bytesBefore);row->setProperty("steadyPrivateGrowthBytes",privateBytes()-bytesPrepared); row->setProperty("medianUs",times[times.size()/2]);row->setProperty("p95Us",times[times.size()*95/100]);row->setProperty("p99Us",times[times.size()*99/100]);row->setProperty("maxUs",times.back());row->setProperty("deadlineOverruns",overruns);row->setProperty("peak",peak);rows.add(row); }; diff --git a/Source/FreePluginSynthDestinationsRegression.h b/Source/FreePluginSynthDestinationsRegression.h index d3b74de..bb897b2 100644 --- a/Source/FreePluginSynthDestinationsRegression.h +++ b/Source/FreePluginSynthDestinationsRegression.h @@ -31,6 +31,6 @@ inline juce::var checkSynthDestinations() juce::MemoryBlock state,again;source->getStateInformation(state);copy->setStateInformation(state.getData(),static_cast(state.getSize()));copy->getStateInformation(again);recall=recall&&state==again; } for(int slot=1;slot<=8;++slot){const auto id="matrix"+juce::String(slot)+"Target";legacy=legacy&&setFreePluginNormalizedForRegression(*source,id,1);const auto schema=describeFreePluginForRegression(*source);for(const auto& parameter:*schema["parameters"].getArray())if(parameter["id"].toString()==id)legacy=legacy&&static_cast(parameter["value"])==(slot<=3?3:7);} - const auto schema=describeFreePluginForRegression(*source);const bool appended=schema["parameters"].size()==86&&schema["parameters"][78]["id"].toString()=="matrix1TargetFull"&&schema["parameters"][64]["id"].toString()=="matrix1SourceExpanded"; + const auto schema=describeFreePluginForRegression(*source);const bool appended=schema["parameters"].size()>=86&&schema["parameters"][78]["id"].toString()=="matrix1TargetFull"&&schema["parameters"][64]["id"].toString()=="matrix1SourceExpanded"; result->setProperty("plugin","Expanded per-voice Synth destinations");result->setProperty("cases",cases);result->setProperty("allDestinationsChangeAudio",influence);result->setProperty("finite",finite);result->setProperty("partitionError",partition);result->setProperty("allRouteStateRoundTrips",recall);result->setProperty("legacyNormalizedRanges",legacy);result->setProperty("appendedDescriptors",appended);result->setProperty("schema",schema);result->setProperty("audioQuality","not_asserted");result->setProperty("pass",influence&&finite&&partition==0&&recall&&legacy&&appended);return result; } diff --git a/Source/FreePluginSynthWorkRegression.h b/Source/FreePluginSynthWorkRegression.h new file mode 100644 index 0000000..7838720 --- /dev/null +++ b/Source/FreePluginSynthWorkRegression.h @@ -0,0 +1,92 @@ +#pragma once + +inline juce::var checkSynthCachedWorkTransitions() +{ + struct Capture + { + juce::AudioBuffer audio { 2, 8192 }; + int maximumVoices = 0; + bool finite = true; + }; + const auto render = [](double rate, bool corrected, bool expressive, int partition) + { + auto synth = std::make_unique(); + synth->signalPath.store(corrected ? 1.0f : 0.0f); + synth->mpeEnabled.store(expressive ? 1.0f : 0.0f); + synth->mpeLowerMembers.store(4); + synth->attackMs.store(7); synth->decayMs.store(90); synth->sustain.store(.6f); synth->releaseMs.store(50); + synth->filterMode.store(1); synth->filterCutoff.store(2400); synth->filterEnvelopeSource.store(1); + synth->macro1.store(.35f); + synth->matrix1Source.store(9); synth->matrix1Target.store(10); synth->matrix1Amount.store(.5f); + synth->matrix2Source.store(9); synth->matrix2Target.store(11); synth->matrix2Amount.store(-.4f); + synth->matrix3Source.store(9); synth->matrix3Target.store(13); synth->matrix3Amount.store(.3f); + synth->matrix4Source.store(9); synth->matrix4Target.store(12); synth->matrix4Amount.store(.2f); + synth->outputGain.store(-30); + synth->prepareToPlay(rate, partition); + const int firstChannel = expressive ? 2 : 1; + juce::MidiBuffer events, midi; + for (int voice = 0; voice < 32; ++voice) + events.addEvent(juce::MidiMessage::noteOn(firstChannel + voice / 16, 40 + voice % 16, .65f), 0); + events.addEvent(juce::MidiMessage::pitchWheel(firstChannel, 12900), 117); + events.addEvent(juce::MidiMessage::controllerEvent(firstChannel + 1, 10, 18), 983); + events.addEvent(juce::MidiMessage::controllerEvent(firstChannel, 11, 83), 1703); + events.addEvent(juce::MidiMessage::pitchWheel(firstChannel + 1, 3100), 2301); + if (expressive) + { + events.addEvent(juce::MidiMessage::pitchWheel(1, 11900), 2507); + events.addEvent(juce::MidiMessage::controllerEvent(1, 10, 97), 3109); + } + events.addEvent(juce::MidiMessage::controllerEvent(firstChannel, 121, 0), 3557); + events.addEvent(juce::MidiMessage::controllerEvent(firstChannel + 1, 10, 64), 4501); + for (int voice = 0; voice < 32; ++voice) + events.addEvent(juce::MidiMessage::noteOff(firstChannel + voice / 16, 40 + voice % 16), 5701 + voice); + Capture result; + juce::AudioBuffer block(2, partition); + const std::array changes { 2048, 4096, 8192 }; + size_t change = 0; + for (int position = 0; position < 8192;) + { + if (position == 2048) { synth->macro1.store(.82f); synth->decayMs.store(170); } + if (position == 4096) { synth->macro1.store(0); synth->attackMs.store(12); synth->releaseMs.store(80); } + while (position >= changes[change]) ++change; + const int count = juce::jmin(partition, changes[change] - position); + block.setSize(2, count, false, false, true); + midi.clear(); midi.addEvents(events, position, count, -position); + synth->processBlock(block, midi); + result.maximumVoices = juce::jmax(result.maximumVoices, synth->getRenderingVoiceCountForRegression()); + for (int ch = 0; ch < 2; ++ch) + { + result.audio.copyFrom(ch, position, block, ch, 0, count); + for (int i = 0; i < count; ++i) result.finite = result.finite && std::isfinite(block.getSample(ch, i)); + } + position += count; + } + return result; + }; + bool pass = true; + juce::Array rows; + for (double rate : { 48000.0, 96000.0 }) for (bool corrected : { false, true }) for (bool expressive : { false, true }) + { + const auto reference = render(rate, corrected, expressive, 31); + for (int partition : { 127, 511 }) + { + const auto actual = render(rate, corrected, expressive, partition); + double maximumError = 0; + for (int ch = 0; ch < 2; ++ch) for (int i = 0; i < 8192; ++i) + maximumError = juce::jmax(maximumError, std::abs(static_cast(actual.audio.getSample(ch, i) - reference.audio.getSample(ch, i)))); + const bool okay = actual.finite && reference.finite && maximumError == 0 && actual.maximumVoices == 32 && reference.maximumVoices == 32; + pass = pass && okay; + auto* row = new juce::DynamicObject(); + row->setProperty("sampleRate", rate); row->setProperty("signalPath", corrected ? 1 : 0); + row->setProperty("mpe", expressive); row->setProperty("partition", partition); + row->setProperty("maximumRenderingVoices", actual.maximumVoices); row->setProperty("maximumError", maximumError); + row->setProperty("pass", okay); rows.add(row); + } + } + auto* result = new juce::DynamicObject(); + result->setProperty("plugin", "Synth cached work, MIDI, MPE and automation partitions"); + result->setProperty("cases", rows); result->setProperty("pass", pass); + result->setProperty("scope", "32 sounding voices; MIDI/MPE bend and pan smoothing, controller reset, modulated amp ADSR and parameter automation at identical sample positions."); + result->setProperty("audioQuality", "not_asserted"); + return result; +} diff --git a/Source/Main.cpp b/Source/Main.cpp index cfca663..6b71ad3 100644 --- a/Source/Main.cpp +++ b/Source/Main.cpp @@ -1,5 +1,7 @@ #include "RegressionMessagePump.h" #include "AudioDeviceProbe.h" +#include "NAMLiveCallbackProbe.h" +#include "RealtimeTrackExecutorRegression.h" #include "RuntimeLocation.h" #include #include "ApplicationLaunchState.h" @@ -977,7 +979,7 @@ int runHeadlessRenderExportRegression(AudioEngine& audioEngine, const juce::File& outputDirectory, const juce::File& reportFile) { - auto result = audioEngine.runRenderExportRegression(outputDirectory); + auto result = audioEngine.runRenderExportRegression(outputDirectory, true); const bool wroteReport = writeHeadlessResult(reportFile, result); const bool pass = result.isObject() && result.getProperty("objectiveGateStatus", {}).toString() == "pass"; @@ -1254,6 +1256,71 @@ class OpenStudioApplication : public juce::JUCEApplication } quit(); return; } + const auto realtimeTest = getCommandLineOptionValue(commandLine, "--realtime-track-self-test"); + if (realtimeTest.isNotEmpty()) + { + auto* report = new juce::DynamicObject(); + const auto executor = runRealtimeTrackExecutorRegression(); + const auto eligibility = TrackProcessor::runParallelNAMEligibilityRegression(); + const auto engine = AudioEngine::runParallelTrackRegression(); + report->setProperty("executor", executor); + report->setProperty("eligibility", eligibility); + report->setProperty("engine", engine); + const bool passed = static_cast(executor["pass"]) && static_cast(eligibility["pass"]) + && static_cast(engine["pass"]); + report->setProperty("pass", passed); + const juce::File target(realtimeTest); + const bool written = target.getParentDirectory().createDirectory() + && target.replaceWithText(juce::JSON::toString(juce::var(report), true)); + setApplicationReturnValue(passed && written ? 0 : 1); + quit(); return; + } + const auto namLiveProbe = getCommandLineOptionValue(commandLine, "--nam-live-callback-probe"); + if (namLiveProbe.isNotEmpty()) + { + const auto output = getCommandLineOptionValue(commandLine, "--output-dir"); + const auto guitar = getCommandLineOptionValue(commandLine, "--guitar-preset"); + const auto bass = getCommandLineOptionValue(commandLine, "--bass-preset"); + NAMLiveCallbackProbe::Options options; + const auto tracks = getCommandLineOptionValue(commandLine, "--probe-tracks"); + const auto rate = getCommandLineOptionValue(commandLine, "--probe-rate"); + const auto oversampling = getCommandLineOptionValue(commandLine, "--probe-oversampling"); + const auto warmup = getCommandLineOptionValue(commandLine, "--probe-warmup-seconds"); + const auto seconds = getCommandLineOptionValue(commandLine, "--probe-seconds"); + const auto workers = getCommandLineOptionValue(commandLine, "--probe-workers"); + const auto spin = getCommandLineOptionValue(commandLine, "--probe-worker-spin-us"); + const auto grace = getCommandLineOptionValue(commandLine, "--probe-worker-grace-us"); + const auto buffers = getCommandLineOptionValue(commandLine, "--probe-buffers"); + if (tracks.isNotEmpty()) options.trackCount = tracks.getIntValue(); + if (rate.isNotEmpty()) options.sampleRate = rate.getDoubleValue(); + if (oversampling.isNotEmpty()) options.oversamplingFactor = oversampling.getIntValue(); + if (warmup.isNotEmpty()) options.warmupSeconds = warmup.getIntValue(); + if (seconds.isNotEmpty()) options.measurementSeconds = seconds.getIntValue(); + if (workers.isNotEmpty()) options.workerCount = workers.getIntValue(); + if (spin.isNotEmpty()) options.workerSpinMicroseconds = spin.getIntValue(); + if (grace.isNotEmpty()) options.recoveryGraceMicroseconds = grace.getIntValue(); + if (buffers.isNotEmpty()) + { + options.buffers.clear(); + for (const auto& value : juce::StringArray::fromTokens(buffers, ",", "")) + options.buffers.push_back(value.getIntValue()); + } + options.recording = commandLineHasFlag(commandLine, "--probe-recording"); + #if JUCE_WINDOWS + ::SetPriorityClass(::GetCurrentProcess(), ABOVE_NORMAL_PRIORITY_CLASS); + #endif + setApplicationReturnValue(output.isNotEmpty() && guitar.isNotEmpty() && bass.isNotEmpty() + ? NAMLiveCallbackProbe::run(namLiveProbe, juce::File(guitar), juce::File(bass), juce::File(output), options) : 2); + quit(); return; + } + const auto audioBufferProbeName = getCommandLineOptionValue(commandLine, "--audio-buffer-probe"); + if (audioBufferProbeName.isNotEmpty()) + { + const auto output = getCommandLineOptionValue(commandLine, "--output-dir"); + setApplicationReturnValue(output.isNotEmpty() + ? AudioDeviceProbe::runSilentASIOBufferChange(audioBufferProbeName, juce::File(output)) : 2); + quit(); return; + } const auto audioProbeName = getCommandLineOptionValue(commandLine, "--audio-device-probe"); if (audioProbeName.isNotEmpty()) { @@ -1343,6 +1410,8 @@ class OpenStudioApplication : public juce::JUCEApplication const auto pluginScanSearchPathsFile = getCommandLineOptionValue(commandLine, "--plugin-search-paths-file"); const auto pluginScanRegressionHeadlessMode = commandLineHasFlag(commandLine, "--plugin-scan-regression-headless"); const auto windowLifecycleHarnessMode = commandLineHasFlag(commandLine, "--window-lifecycle-harness"); + const bool windowLifecycleOffline = windowLifecycleHarnessMode + && commandLineHasFlag(commandLine, "--window-lifecycle-offline"); ScopedHeadlessRegressionMessages headlessMessages( automatedRegressionHeadlessMode || renderExportRegressionHeadlessMode || pitchRegressionHeadlessJobPath.isNotEmpty() @@ -1526,7 +1595,11 @@ class OpenStudioApplication : public juce::JUCEApplication quit(); return; } - audioEngine = std::make_unique(); + const bool focusedNAMOffline = namRackRegressionReportPath.isNotEmpty() + && (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_ONLY", {}) == "1" + || juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_TWO_RACK_CPU_ONLY", {}) == "1"); + audioEngine = std::make_unique(focusedNAMOffline || windowLifecycleOffline || renderExportRegressionHeadlessMode + ? AudioEngine::DeviceStartup::offline : AudioEngine::DeviceStartup::openHardware); audioEngine->onFXSlotsRemoved = [this](const juce::String& trackId, const juce::String& chain, int firstIndex) { @@ -1663,9 +1736,9 @@ class OpenStudioApplication : public juce::JUCEApplication const int editorReviewHoldMs = juce::jlimit(0, 60000, getCommandLineOptionValue(commandLine, "--plugin-editor-review-hold-ms").getIntValue()); - juce::Timer::callAfterDelay(1000, [this, reportFile, editorReviewHoldMs]() + juce::Timer::callAfterDelay(1000, [this, reportFile, editorReviewHoldMs, windowLifecycleOffline]() { - runWindowLifecycleHarness(reportFile, editorReviewHoldMs); + runWindowLifecycleHarness(reportFile, editorReviewHoldMs, windowLifecycleOffline); }); } } @@ -1951,13 +2024,13 @@ class OpenStudioApplication : public juce::JUCEApplication { "OpenStudio Reverb", "reverb" }, { "OpenStudio Delay", "delay" }, { "OpenStudio Chorus", "chorus" }, { "OpenStudio Basic Synth", "synth" }, { "OpenStudio Piano", "piano" }, { "OpenStudio Clean Guitar", "guitar" }, - { "OpenStudio Drums", "drums" } + { "OpenStudio Drums", "drums" }, { "OpenStudio Pitch Correct", "pitch" } }; for (const auto& entry : legacyNames) if (name == entry.first) { pluginId = entry.second; break; } } const juce::StringArray compactIds { "eq", "geq", "compressor", "gate", "limiter", "preamp", - "saturator", "utility", "reverb", "delay", "chorus", "synth", "piano", "guitar", "drums" }; + "saturator", "utility", "reverb", "delay", "chorus", "synth", "piano", "guitar", "drums", "pitch" }; PluginEditorWindowGeometry result; if (compactIds.contains(pluginId)) { @@ -2331,7 +2404,8 @@ class OpenStudioApplication : public juce::JUCEApplication MainComponent::broadcastEventToRole(MainComponent::WindowRole::main, "builtInPluginEditorWindowClosed", juce::var(payload)); } - void runWindowLifecycleHarness(const juce::File& reportFile, int editorReviewHoldMs = 0) + void runWindowLifecycleHarness(const juce::File& reportFile, int editorReviewHoldMs = 0, + bool offlineRequested = false) { pitchRecoveryFileOverride = reportFile.getSiblingFile(reportFile.getFileNameWithoutExtension() + "-recovery.json"); pitchRecoveryCheckpoint = juce::var(); @@ -2367,6 +2441,8 @@ class OpenStudioApplication : public juce::JUCEApplication auto* evidence = new juce::DynamicObject(); evidence->setProperty("role", role); evidence->setProperty("documentRequests", requests); + evidence->setProperty("startupState", component != nullptr + ? component->getFrontendStartupStateDescription() : "missing"); frontendDocuments->add(juce::var(evidence)); return component != nullptr && component->hasFrontendStartupSucceeded() && requests == 1; }}); @@ -2433,7 +2509,7 @@ class OpenStudioApplication : public juce::JUCEApplication return mainWindow->getBounds() == originalBounds; }}); const auto probeDriver = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_AUDIO_PROBE_DRIVER", ""); - if (probeDriver.isNotEmpty()) + if (probeDriver.isNotEmpty() && !offlineRequested) steps->push_back({ "audio_capability_query_preserves_active_setup", 0, [this, probeDriver, reportFile]() { const auto before = audioEngine->getAudioDeviceSetup(); const auto stopsBefore = audioEngine->getAudioDebugSnapshot().getProperty("audioDeviceStopCount", {}); @@ -2792,7 +2868,7 @@ class OpenStudioApplication : public juce::JUCEApplication steps->push_back({ "plugin_editor_geometry_contract", 0, [this]() { for (const auto& id : juce::StringArray { "eq", "geq", "compressor", "gate", "limiter", "preamp", - "saturator", "utility", "reverb", "delay", "chorus", "synth", "piano", "guitar", "drums" }) + "saturator", "utility", "reverb", "delay", "chorus", "synth", "piano", "guitar", "drums", "pitch" }) { auto* session = new juce::DynamicObject(); session->setProperty("pluginId", id); @@ -2963,7 +3039,7 @@ class OpenStudioApplication : public juce::JUCEApplication auto stepAttempt = std::make_shared(0); auto runner = std::make_shared>(); const std::weak_ptr> weakRunner = runner; - *runner = [this, reportFile, checks, inputEvidence, frontendDocuments, steps, stepIndex, stepAttempt, weakRunner, midiSessionId]() mutable + *runner = [this, reportFile, checks, inputEvidence, frontendDocuments, steps, stepIndex, stepAttempt, weakRunner, midiSessionId, offlineRequested]() mutable { // Timers own the next invocation. The function must not own itself, // otherwise all check results survive application shutdown. @@ -2972,9 +3048,18 @@ class OpenStudioApplication : public juce::JUCEApplication return; if (*stepIndex >= steps->size()) { + const auto telemetry = audioEngine->getRealtimeAudioTelemetry(); + const bool audioDeviceOpened = audioEngine->getDeviceManager().getCurrentAudioDevice() != nullptr; + const auto deviceStarts = static_cast(telemetry.getProperty("audioDeviceStartCount", -1)); + const auto callbacks = static_cast(telemetry.getProperty("lastCallbackCounter", -1)); + if (offlineRequested) + addHarnessCheck(*checks, "lifecycle_no_hardware_device_opened", + !audioDeviceOpened && deviceStarts == 0 && callbacks == 0 ? "pass" : "fail", + "Explicit offline lifecycle mode requires no audio device, zero device starts and zero hardware callbacks."); const bool success = ! hasFailedHarnessCheck(*checks); auto* root = new juce::DynamicObject(); root->setProperty("harnessMode", "window_lifecycle"); + root->setProperty("executablePath", OpenStudioRuntime::executableFile().getFullPathName()); root->setProperty("startupMode", startupMode == MainComponent::StartupMode::safe ? "safe" : "normal"); root->setProperty("pitchEditing", startupMode == MainComponent::StartupMode::safe ? "not_asserted: recovery UI only" : "objective normal-mode editing checks"); root->setProperty("success", success); @@ -2982,6 +3067,13 @@ class OpenStudioApplication : public juce::JUCEApplication root->setProperty("nativeBrowserComponents", MainComponent::getBrowserInstanceCounts()); root->setProperty("inputMeasurements", juce::var(*inputEvidence)); root->setProperty("frontendDocuments", juce::var(*frontendDocuments)); + root->setProperty("offlineRequested", offlineRequested); + root->setProperty("audioDeviceOpened", audioDeviceOpened); + root->setProperty("audioDeviceStartCount", deviceStarts); + root->setProperty("audioCallbackCount", callbacks); + root->setProperty("startupSelfTest", MainComponent::buildStartupSelfTestReport()); + root->setProperty("browserExecutableFolderOverride", juce::SystemStats::getEnvironmentVariable("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", {})); + root->setProperty("userDataFolderOverride", juce::SystemStats::getEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", {})); root->setProperty("platform", juce::SystemStats::getOperatingSystemName()); root->setProperty("multiMonitorDpi", "not_asserted"); root->setProperty("pitchState", handlePitchEditorSession("status", {}, MainComponent::WindowRole::main, {})); diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index 4bfd806..cbe17a5 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -10339,6 +10339,22 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, const auto targetPresetName = args[2].toString(); completion(audioEngine.copyBuiltInFXPreset(pluginName, sourcePresetName, targetPresetName)); }) + .withNativeFunction ("copyNAMRackPresetWithInstrumentProfile", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() != 3 || ! args[0].isString() || ! args[1].isString() + || ! (args[2].isInt() || args[2].isInt64() || args[2].isDouble())) + { + completion(false); + return; + } + const auto profile = static_cast(args[2]); + if (profile != 0.0 && profile != 1.0) + { + completion(false); + return; + } + completion(audioEngine.copyNAMRackPresetWithInstrumentProfile( + args[0].toString(), args[1].toString(), static_cast(profile))); + }) .withNativeFunction ("deleteBuiltInFXPreset", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { auto pluginName = args[0].toString(); auto presetName = args[1].toString(); @@ -12067,6 +12083,27 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("saveJsonDocumentToFile", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() != 2 || !args[0].isString() || !args[1].isString() + || !juce::File::isAbsolutePath(args[0].toString())) + { completion(false); return; } + const auto file = juce::File(args[0].toString()); + const auto json = args[1].toString(); + if (json.getNumBytesAsUTF8() > ProjectFileStore::maximumJsonDocumentBytes) + { completion(false); return; } + const juce::Component::SafePointer safeThis(this); + projectFilePool.addJob([file, json, completion, safeThis] { + #if JUCE_WINDOWS + ::SetThreadPriority(::GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL); + #endif + const auto result = ProjectFileStore::saveJsonDocument(file, json); + const bool success = result.wasOk(); + if (!success) OpenStudioCrashDiagnostics::recordBreadcrumb("json_document_save_failed", result.getErrorMessage()); + juce::MessageManager::callAsync([completion, success, safeThis] { + if (safeThis && !safeThis->secondaryWindowClosing) completion(success); + }); + }); + }) .withNativeFunction ("projectRecovery", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (!isMainWindow() || args.isEmpty()) { completion(juce::Array()); return; } const auto action = args[0].toString(); @@ -17444,6 +17481,11 @@ void MainComponent::timerCallback() windowCallbacks.pitchEditorSession("tick", {}, windowRole, windowInstanceId); if (isMainWindow() && audioEngine.finalizeInterruptedRecording()) pendingRecordingDeviceInterruption = true; + // Preview storage must keep up even when the timeline is hidden or zooming + // suppresses waveform requests. This control-thread maintenance never + // acquires the recorder's audio-writer lock. + if (isMainWindow()) + audioEngine.maintainRecordingPreviews(); if (isMainWindow() && frontendStartupState == FrontendStartupState::ready) OpenStudioCrashDiagnostics::pulseMessageThread(); OpenStudioCrashDiagnostics::flushRealtimeFaults(); diff --git a/Source/NAMFuzz.h b/Source/NAMFuzz.h new file mode 100644 index 0000000..1dc9910 --- /dev/null +++ b/Source/NAMFuzz.h @@ -0,0 +1,399 @@ +#pragma once + +#include +#include +#include +#include + +/** Original, physically informed fuzz voices, not circuit-exact pedal replicas. + Each voice has separate stereo state, coupling/tone filters and two nonlinear + cells. First-order antiderivative antialiasing integrates each clipping cell + over the interval between samples (Holters, DAFx 2019). The half-sample cell + delay is part of its filtering; no additional integer-latency island or host + PDC change is introduced. Bypass is exactly the incoming octave-stage output. + All controls and voice crossfades ramp in sample time. No callback allocation, + locks, disk access, or coefficient design inside the sample loop. */ +class NAMFuzz final +{ +public: + struct Parameters + { + bool enabled = false; + float drive = 0.5f; + float tone = 0.5f; + float levelDb = 0.0f; + int voice = 0; // Vintage, Sustain, Gated + float cleanMix = 0.0f; + bool bass = false; + float calibrationReferenceDbu = 12.0f; + }; + + // Calling-thread QA view only; the host never reads this concurrently with + // processing and it is not persisted or published across the native bridge. + struct GateDiagnostics + { + std::array open {}; + std::array opens {}, closes {}; + std::array envelope {}, gain {}; + }; + + [[nodiscard]] GateDiagnostics getGateDiagnostics() const noexcept + { + GateDiagnostics result; + for (size_t channel = 0; channel < channels.size(); ++channel) + { + const auto& state = channels[channel]; + result.open[channel] = state.gateOpen; + result.opens[channel] = state.gateOpens; + result.closes[channel] = state.gateCloses; + result.envelope[channel] = state.gateEnvelope; + result.gain[channel] = state.gate; + } + return result; + } + + void prepare(double rate, const Parameters& parameters) noexcept + { + sampleRate = std::isfinite(rate) && rate >= 8000.0 && rate <= 384000.0 ? rate : 44100.0; + rampSamples = juce::jmax(1, static_cast(sampleRate * 0.025)); + envelopeAttack = pole(0.001f); + envelopeRelease = pole(0.080f); + gateAttack = pole(0.003f); + gateRelease = pole(0.012f); + bassGateEnvelopeRelease = pole(0.120f); + bassGateRelease = pole(0.030f); + bassGateHoldSamples = juce::jmax(1, juce::roundToInt(sampleRate * 0.065)); + dcPole = lowPassPole(12.0f); + parametersCached = false; // A new rate must redesign even unchanged controls. + setParameters(parameters); + for (auto* ramp : ramps()) ramp->finish(); + for (auto& ramp : voiceWeights) ramp.finish(); + reset(); + } + + void reset() noexcept + { + channels = {}; + wasProcessing = false; + } + + void setParameters(const Parameters& raw) noexcept + { + if (parametersCached + && raw.enabled == cachedParameters.enabled + && raw.drive == cachedParameters.drive + && raw.tone == cachedParameters.tone + && raw.levelDb == cachedParameters.levelDb + && raw.voice == cachedParameters.voice + && raw.cleanMix == cachedParameters.cleanMix + && raw.bass == cachedParameters.bass + && raw.calibrationReferenceDbu == cachedParameters.calibrationReferenceDbu) + return; + cachedParameters = raw; + parametersCached = true; + const auto bounded = [] (float value, float low, float high, float fallback) + { + return std::isfinite(value) ? juce::jlimit(low, high, value) : fallback; + }; + const float drive = bounded(raw.drive, 0.0f, 1.0f, 0.5f); + const float tone = bounded(raw.tone, 0.0f, 1.0f, 0.5f); + const float levelDb = bounded(raw.levelDb, -18.0f, 12.0f, 0.0f); + bassMode = raw.bass; + vintageCoupling.target(raw.bass ? 0.0f : 1.0f, rampSamples); + power.target(raw.enabled ? 1.0f : 0.0f, rampSamples); + amount.target(drive, rampSamples); + toneBalance.target(tone, rampSamples); + level.target(juce::Decibels::decibelsToGain(levelDb), rampSamples); + clean.target(bounded(raw.cleanMix, 0.0f, 1.0f, 0.0f), rampSamples); + // Match the rack's embedded pedals: convert the interface's 0 dBFS + // reference into a fixed +12 dBu operating domain. Reciprocal recovery + // below preserves the represented analog output and the raw clean path. + const float referenceDbu = bounded(raw.calibrationReferenceDbu, -24.0f, 48.0f, 12.0f); + operatingGain.target(juce::Decibels::decibelsToGain( + juce::jlimit(-36.0f, 36.0f, referenceDbu - 12.0f)), rampSamples); + // Fixed control-derived compensation for the center of the scooped + // tone network. Do not chase bright-end RMS by amplifying its peaks. + const float centerWeight = 1.0f - std::abs(2.0f * tone - 1.0f); + const float makeupDb = centerWeight * (3.0f + 2.0f * juce::jmin(1.0f, 2.0f * drive)); + sustainMakeup.target(juce::Decibels::decibelsToGain(makeupDb), rampSamples); + inputHighPass.target(lowPassPole(raw.bass ? 12.0f : 28.0f), rampSamples); + inputLowPass.target(lowPassPole(raw.bass ? 6500.0f : 8500.0f), rampSamples); + interstageLowPass.target(lowPassPole(raw.bass ? 3600.0f : 5100.0f), rampSamples); + toneLowPass.target(lowPassPole((raw.bass ? 800.0f : 1000.0f) + * std::pow(raw.bass ? 8.0f : 10.0f, tone)), rampSamples); + sustainLowPass.target(lowPassPole(raw.bass ? 240.0f : 470.0f), rampSamples); + sustainHighPass.target(lowPassPole(raw.bass ? 800.0f : 1400.0f), rampSamples); + const int voice = juce::jlimit(0, 2, raw.voice); + for (int index = 0; index < 3; ++index) + voiceWeights[static_cast(index)].target(index == voice ? 1.0f : 0.0f, rampSamples); + } + + void processBlock(juce::AudioBuffer& buffer, const float* excitationGains = nullptr) noexcept + { + const int count = juce::jmin(2, buffer.getNumChannels()); + if (count == 0 || buffer.getNumSamples() == 0) return; + if (!wasProcessing && power.current == 0.0f && power.destination == 0.0f) + { + bool moving = false; + for (const auto* ramp : ramps()) moving = moving || ramp->remaining > 0; + for (const auto& ramp : voiceWeights) moving = moving || ramp.remaining > 0; + if (!moving) return; + } + std::array data { buffer.getWritePointer(0), + count > 1 ? buffer.getWritePointer(1) : nullptr }; + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + { + const float wetPower = power.next(); + const float drive = amount.next(); + const float tone = toneBalance.next(); + const float gain = level.next(); + const float cleanBlend = clean.next(); + const float calibratedGain = operatingGain.next(); + const float couplingBlend = vintageCoupling.next(); + const float sustainGain = sustainMakeup.next(); + const float hp = inputHighPass.next(); + const float lp = inputLowPass.next(); + const float inter = interstageLowPass.next(); + const float post = toneLowPass.next(); + const float low = sustainLowPass.next(); + const float high = sustainHighPass.next(); + const float excitation = excitationGains && std::isfinite(excitationGains[sample]) + ? juce::jlimit(0.0f, 1.0f, excitationGains[sample]) : 1.0f; + std::array weights {}; + for (size_t voice = 0; voice < weights.size(); ++voice) + weights[voice] = voiceWeights[voice].next(); + // Complete bypass at its exact sample boundary. Retiring histories + // only on the next callback would make a quick reactivation depend + // on block size. Every control ramp also advances during bypass, + // including any zero-wet suffix of this callback. + if (wetPower == 0.0f && power.destination == 0.0f) + { + if (wasProcessing) reset(); + continue; // Leave incoming samples exactly unchanged. + } + wasProcessing = true; + for (int channel = 0; channel < count; ++channel) + { + auto& state = channels[static_cast(channel)]; + const float dry = std::isfinite(data[static_cast(channel)][sample]) + ? data[static_cast(channel)][sample] : 0.0f; + const float boundedInput = juce::jlimit(-16.0f, 16.0f, dry); + const float calibratedInput = boundedInput * calibratedGain; + const float detectorInput = filter(calibratedInput - filter(calibratedInput, hp, state.inputCoupling), lp, state.inputBand); + // Octave-mixer headroom affects the nonlinear cells and sag, + // while the clean branch and gate retain their incoming level. + const float input = detectorInput * excitation; + const float absolute = std::abs(input); + const float envPole = absolute > state.envelope ? envelopeAttack : envelopeRelease; + state.envelope = envPole * state.envelope + (1.0f - envPole) * absolute; + const float sag = juce::jlimit(0.0f, 1.0f, state.envelope * 3.0f); + // Leave a usable low/mid Drive range before another gain pedal. + // The former quadratic already supplied 22.5x at noon, so + // quiet pickup noise could fully excite a downstream stack. + // Preserve the 75x endpoint and continuous ungated sustain; + // this changes excitation, not the final loudness control. + const float driveSquared = drive * drive; + const float driveGain = 1.5f + 73.5f * driveSquared * driveSquared; + const float gatedDriveGain = 5.0f + 70.0f * drive * drive; + + // Vintage: unequal transistor knees, dynamic bias and coupling + // between two gain stages. Sag changes the response continuously. + auto& vintage = state.voices[0]; + float vintageOut = clip(input * driveGain / (1.0f + 0.65f * sag), + 0.62f, 0.88f, 0.10f - 0.12f * sag, vintage.cells[0]); + vintageOut = filter(vintageOut, inter, vintage.interstage); + // AC coupling keeps the first asymmetric cell's signal-dependent + // DC from becoming an unintended bias on the second cell. Keep + // the history warm in Bass, whose existing sub-octave path is + // retained without another high-pass stage. + const float coupled = vintageOut - vintage.couplingInput + dcPole * vintage.couplingOutput; + vintage.couplingInput = vintageOut; + vintage.couplingOutput = std::abs(coupled) < 1.0e-20f ? 0.0f : coupled; + if (couplingBlend == 1.0f) + vintageOut = vintage.couplingOutput; + else if (couplingBlend > 0.0f) + vintageOut += couplingBlend * (vintage.couplingOutput - vintageOut); + vintageOut = clip(vintageOut * (2.0f + 2.5f * drive), + 0.55f, 0.78f, 0.07f + 0.10f * sag, vintage.cells[1]) * 1.45f; + + // Sustain: cascaded symmetric diode-like cells followed by + // complementary low/high branches with a broad mid scoop. + // Keep a 15% low-frequency floor in the bright branch: a pure + // high-pass adds almost 90 degrees at low frequencies and can + // oppose the immediate clean blend after coupling/DC filters. + auto& sustain = state.voices[1]; + float sustainOut = clip(input * driveGain * 0.75f, + 0.70f, 0.70f, 0.0f, sustain.cells[0]); + sustainOut = filter(sustainOut, inter, sustain.interstage); + sustainOut = clip(sustainOut * (2.8f + 2.2f * drive), + 0.68f, 0.68f, 0.0f, sustain.cells[1]); + const float lowBranch = filter(sustainOut, low, sustain.lowBand); + const float highBranch = sustainOut - 0.85f * filter(sustainOut, high, sustain.highBand); + sustainOut = 1.65f * sustainGain * ((1.0f - tone) * lowBranch + tone * highBranch); + + // Gated: deliberately starved asymmetric cells. Guitar retains + // its soft gate; Bass gets a separate, longer detector plus + // hysteresis/hold so low notes do not close on envelope troughs. + auto& gated = state.voices[2]; + float gatedOut = clip(input * gatedDriveGain * 1.35f, + 0.40f, 0.80f, 0.28f + 0.12f * sag, gated.cells[0]); + gatedOut = filter(gatedOut, inter, gated.interstage); + gatedOut = clip(gatedOut * 3.8f, + 0.50f, 0.75f, 0.20f, gated.cells[1]); + const float threshold = 0.0015f + 0.003f * (1.0f - drive); + const float detectorAbsolute = std::abs(detectorInput); + const float detectorRelease = bassMode ? bassGateEnvelopeRelease : envelopeRelease; + const float detectorPole = detectorAbsolute > state.gateEnvelope ? envelopeAttack : detectorRelease; + state.gateEnvelope = detectorPole * state.gateEnvelope + (1.0f - detectorPole) * detectorAbsolute; + float gateTarget = 0.0f; + const bool previouslyOpen = state.gateOpen; + if (bassMode) + { + if (state.gateEnvelope >= threshold) + { + state.gateOpen = true; + state.belowCloseSamples = 0; + } + else if (state.gateOpen && state.gateEnvelope < threshold * 0.5f) + { + if (++state.belowCloseSamples >= bassGateHoldSamples) + { + state.gateOpen = false; + state.belowCloseSamples = 0; + } + } + else + { + state.belowCloseSamples = 0; + } + gateTarget = state.gateOpen ? 1.0f : 0.0f; + } + else + { + gateTarget = juce::jlimit(0.0f, 1.0f, + (state.gateEnvelope - threshold) / (threshold * 3.0f)); + state.gateOpen = gateTarget > 0.0f; + state.belowCloseSamples = 0; + } + if (state.gateOpen && !previouslyOpen) ++state.gateOpens; + if (!state.gateOpen && previouslyOpen) ++state.gateCloses; + const float gatePole = gateTarget > state.gate ? gateAttack : (bassMode ? bassGateRelease : gateRelease); + state.gate = gatePole * state.gate + (1.0f - gatePole) * gateTarget; + gatedOut *= state.gate * 1.5f; + + float wet = weights[0] * filter(vintageOut, post, vintage.outputTone) + + weights[1] * filter(sustainOut, post, sustain.outputTone) + + weights[2] * filter(gatedOut, post, gated.outputTone); + const float blocked = wet - state.dcInput + dcPole * state.dcOutput; + state.dcInput = wet; + state.dcOutput = std::abs(blocked) < 1.0e-20f ? 0.0f : blocked; + // A fixed common wet trim leaves room for voice switching; + // it never changes the true-clean endpoint or follows audio. + wet = gain * ((1.0f - cleanBlend) * 0.86f * state.dcOutput / calibratedGain + + cleanBlend * boundedInput); + const float output = dry + wetPower * (wet - dry); + data[static_cast(channel)][sample] = std::isfinite(output) ? output : 0.0f; + } + } + } + +private: + struct Ramp + { + float current = 0.0f, destination = 0.0f, step = 0.0f; + int remaining = 0; + void target(float value, int samples) noexcept + { + if (destination == value) return; + destination = value; + remaining = samples; + step = (destination - current) / static_cast(samples); + } + float next() noexcept + { + if (remaining > 0) + { + current += step; + if (--remaining == 0) current = destination; + } + return current; + } + void finish() noexcept { current = destination; remaining = 0; step = 0.0f; } + }; + struct Cell { float previous = 0.0f; }; + struct Voice + { + std::array cells {}; + float interstage = 0.0f, outputTone = 0.0f, lowBand = 0.0f, highBand = 0.0f; + float couplingInput = 0.0f, couplingOutput = 0.0f; + }; + struct Channel + { + std::array voices {}; + float inputCoupling = 0.0f, inputBand = 0.0f, envelope = 0.0f, gate = 0.0f; + float dcInput = 0.0f, dcOutput = 0.0f; + float gateEnvelope = 0.0f; + int belowCloseSamples = 0; + bool gateOpen = false; + std::uint64_t gateOpens = 0, gateCloses = 0; + }; + std::array ramps() noexcept + { + return { &power, &amount, &toneBalance, &level, &clean, &operatingGain, &vintageCoupling, &sustainMakeup, &inputHighPass, + &inputLowPass, &interstageLowPass, &toneLowPass, &sustainLowPass, &sustainHighPass }; + } + float pole(float seconds) const noexcept + { return static_cast(std::exp(-1.0 / (sampleRate * seconds))); } + float lowPassPole(float frequency) const noexcept + { + const double cutoff = juce::jmin(static_cast(frequency), sampleRate * 0.40); + return static_cast(std::exp(-juce::MathConstants::twoPi * cutoff / sampleRate)); + } + static float filter(float input, float coefficient, float& state) noexcept + { + state = coefficient * state + (1.0f - coefficient) * input; + if (std::abs(state) < 1.0e-20f) state = 0.0f; + return state; + } + static double curve(double input, double positive, double negative) noexcept + { + const double knee = input >= 0.0 ? positive : negative; + const double unit = juce::jlimit(-1.0, 1.0, input / knee); + return knee * (unit - unit * unit * unit / 3.0); + } + static double integral(double input, double positive, double negative) noexcept + { + const double knee = input >= 0.0 ? positive : negative; + const double absolute = std::abs(input); + if (absolute >= knee) + return 5.0 * knee * knee / 12.0 + 2.0 * knee * (absolute - knee) / 3.0; + const double squared = input * input; + return squared * 0.5 - squared * squared / (12.0 * knee * knee); + } + static float clip(float input, float positive, float negative, float bias, Cell& state) noexcept + { + const double previous = state.previous; + state.previous = input; + const double delta = static_cast(input) - previous; + const double offset = curve(bias, positive, negative); + const double output = std::abs(delta) < 1.0e-5 + ? curve((static_cast(input) + previous) * 0.5 + bias, positive, negative) - offset + : (integral(static_cast(input) + bias, positive, negative) + - integral(previous + bias, positive, negative)) / delta - offset; + return static_cast(output); + } + double sampleRate = 44100.0; + int rampSamples = 1103; + float envelopeAttack = 0.0f, envelopeRelease = 0.0f, gateAttack = 0.0f, gateRelease = 0.0f, dcPole = 0.0f; + float bassGateEnvelopeRelease = 0.0f, bassGateRelease = 0.0f; + int bassGateHoldSamples = 1; + bool bassMode = false; + Ramp power, amount, toneBalance, level, clean, operatingGain, vintageCoupling; + Ramp sustainMakeup; + Ramp inputHighPass, inputLowPass, interstageLowPass, toneLowPass, sustainLowPass, sustainHighPass; + std::array voiceWeights {}; + std::array channels {}; + Parameters cachedParameters; + bool parametersCached = false; + bool wasProcessing = false; +}; diff --git a/Source/NAMFuzzAliasRegression.h b/Source/NAMFuzzAliasRegression.h new file mode 100644 index 0000000..e41bafc --- /dev/null +++ b/Source/NAMFuzzAliasRegression.h @@ -0,0 +1,369 @@ +#pragma once + +#include "BuiltInEffects2.h" +#include "NAMNonlinearAliasAnalysis.h" +#include +#include + +// Optional, offline production-Rack measurements. Neither a circuit reference +// nor an audible-quality oracle: rate-dependent filters, ADAA and envelopes +// also contribute to high-rate complex residuals. +class NAMFuzzAliasRegression final +{ +public: + static juce::Array run() + { + juce::Array checks; + if (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_ALIAS", {}) != "1") + return checks; + + const NAMNonlinearAliasAnalysis reference4(4), reference2(2); + juce::Array cases, factors, convergence, controls; + bool integrityPassed = true, controlsPassed = true, inactiveFactorPassed = true; + int inactiveFactorComparisons = 0; + float maximumInactiveFactorDifference = 0.0f; + const auto started = juce::Time::getMillisecondCounterHiRes(); + for (double rate : { 44100.0, 48000.0, 96000.0 }) + { + const auto sourceHigh = makeSource(rate, 4); + const auto sourceBase = reference4.decimate(sourceHigh); + const auto sourceIntermediate = makeSource(rate, 2); + controls.add(measurementControls(rate, sourceHigh, sourceBase, + reference4, controlsPassed)); + for (bool bass : { false, true }) + for (int voice = 0; voice < 3; ++voice) + for (int topology = 0; topology < 8; ++topology) + { + const auto high = render(rate * 4.0, 8, sourceHigh, bass, voice, topology); + const auto reference = reference4.decimate(high.audio); + const auto device = render(rate, 4, sourceBase, bass, voice, topology); + const double advance = device.latencySamples - high.latencySamples / 4.0; + const auto residual = NAMNonlinearAliasAnalysis::measureAlignedResidual( + device.audio, reference, rate, advance, occupiedStride); + const bool valid = high.integrityPassed && device.integrityPassed && residual.finite; + integrityPassed = integrityPassed && valid; + auto row = describe(rate, bass, voice, topology, 4, residual); + addPeriodDiagnostics(row, device.audio, reference, rate, residual); + row.getDynamicObject()->setProperty("deviceIntegrity", device.diagnostics); + row.getDynamicObject()->setProperty("referenceIntegrity", high.diagnostics); + row.getDynamicObject()->setProperty("measurementIntegrityPassed", valid); + cases.add(row); + + // All three voices/profiles/rates get a reference convergence + // witness and Drive factor comparison in the complete cascade. + if (topology == 7) + { + const auto intermediate = render(rate * 2.0, 8, sourceIntermediate, + bass, voice, topology); + const auto intermediateBase = reference2.decimate(intermediate.audio); + const auto residual2 = NAMNonlinearAliasAnalysis::measureAlignedResidual( + intermediateBase, reference, rate, + intermediate.latencySamples / 2.0 - high.latencySamples / 4.0, + occupiedStride); + auto converged = describe(rate, bass, voice, topology, 8, residual2); + addPeriodDiagnostics(converged, intermediateBase, reference, rate, residual2); + converged.getDynamicObject()->setProperty("comparison", "2x-host versus 4x-host reference, both with 8x downstream Drive oversampling"); + converged.getDynamicObject()->setProperty("lowerReferenceIntegrity", intermediate.diagnostics); + convergence.add(converged); + integrityPassed = integrityPassed && intermediate.integrityPassed && residual2.finite; + } + if (topology == 0 || topology == 7) + for (int factor : { 2, 8 }) + { + const auto changed = render(rate, factor, sourceBase, bass, voice, topology); + integrityPassed = integrityPassed && changed.integrityPassed; + if (topology == 0) + { + float maximumDifference = 0.0f; + const int size = static_cast(device.audio.size()); + const int deviceOffset = juce::jlimit(0, size, device.latencySamples); + const int changedOffset = juce::jlimit(0, size, changed.latencySamples); + const int commonSamples = size - juce::jmax(deviceOffset, changedOffset); + for (int sample = 0; sample < commonSamples; ++sample) + maximumDifference = juce::jmax(maximumDifference, + std::abs(device.audio[static_cast(sample + deviceOffset)] + - changed.audio[static_cast(sample + changedOffset)])); + maximumInactiveFactorDifference = juce::jmax( + maximumInactiveFactorDifference, maximumDifference); + inactiveFactorPassed = inactiveFactorPassed && maximumDifference == 0.0f + && commonSamples > 0 && deviceOffset == device.latencySamples + && changedOffset == changed.latencySamples; + ++inactiveFactorComparisons; + } + else + { + const auto changedResidual = NAMNonlinearAliasAnalysis::measureAlignedResidual( + changed.audio, reference, rate, + changed.latencySamples - high.latencySamples / 4.0, occupiedStride); + auto changedRow = describe(rate, bass, voice, topology, factor, changedResidual); + addPeriodDiagnostics(changedRow, changed.audio, reference, rate, changedResidual); + changedRow.getDynamicObject()->setProperty("deviceIntegrity", changed.diagnostics); + factors.add(changedRow); + integrityPassed = integrityPassed && changedResidual.finite; + } + } + } + } + const bool coveragePassed = cases.size() == 144 && factors.size() == 36 + && convergence.size() == 18 && inactiveFactorComparisons == 36; + integrityPassed = integrityPassed && coveragePassed; + auto* report = new juce::DynamicObject(); + const juce::var reportValue(report); + report->setProperty("coverage", "Guitar/Bass x Vintage/Sustain/Gated x44.1/48/96kHz x Drive off/on x Distortion off/Crunch/Heavy/Extreme:144 primary cases at4x Drive. Complete cascades add36 factor2x/8x cases and18 reference-convergence comparisons. Fuzz-only adds36 exact inactive-Drive-factor comparisons after each render's reported integer PDC."); + report->setProperty("route", "Actual NAM Rack Fuzz -> optional Drive -> optional Distortion -> fixed-18dB final Rack output trim -> unchanged production safety guard. Octave, PRE/post EQ, compressor, Global/Drive/Distortion gates, captures, amp, cabinet and time effects bypassed; Clean0, calibration+12dBu. Fuzz Gated retains its intrinsic gate. Input and interstage levels remain unchanged."); + report->setProperty("measurementOutputTrimDb", measurementOutputTrimDb); + report->setProperty("measurementOutputTrimInterpretation", "A fixed post-chain attenuation is installed before prepare for every DUT/reference. It keeps the final safety guard inactive while retaining all clipping-stage excitation. The original0dB measurement engaged that guard and could not isolate the requested stages. No adaptive gain matching, normalization, or production DSP change is applied; zero guard hits remain required."); + report->setProperty("controls", "Fuzz drive0.82, tone0.75, level0dB; Drive drive0.82, bright1, attack0.18, volume0dB; Distortion drive0.82, tone0.58, weight0.5, mix1, level0dB. Single high-excitation control set; continuous controls are covered separately by interaction/lifecycle tests."); + report->setProperty("reference", "4x host rate (at most384kHz), 8x downstream oversampling;513-tap Blackman-Harris decimator at0.45 of base rate, common physical source origin. Reported PDC plus bounded +/-0.5-sample alignment at1/256 resolution; no gain/phase-EQ fitting."); + report->setProperty("stimulus", "Steady5500/9900/13200/18700Hz multisine, amplitudes0.040/0.035/0.030/0.025, phases0.17/1.13/2.41/-0.71;0.28s plus FIR margins, final10ms period, analytically occupied bin stride11. This high-frequency probe is not a guitar/bass recording."); + report->setProperty("interpretation", "diagnostic_only: unoccupied residual includes aliasing, rate-dependent filtering/ADAA and envelope settling. Occupied residual includes desired transfer differences. Consecutive10ms-period relative differences quantify nonstationarity in the actual analysis windows. Reference convergence is reported, not assumed. No residual threshold or subjective quality pass is assigned to Fuzz/cascades."); + report->setProperty("subjectiveQuality", "not_asserted"); + report->setProperty("measurementControlsPassed", controlsPassed); + report->setProperty("renderIntegrityPassed", integrityPassed); + report->setProperty("coveragePassed", coveragePassed); + report->setProperty("inactiveDriveFactorPassed", inactiveFactorPassed); + report->setProperty("measurementControls", juce::var(controls)); + report->setProperty("cases", juce::var(cases)); + report->setProperty("factorDiagnostics", juce::var(factors)); + report->setProperty("referenceConvergenceDiagnostics", juce::var(convergence)); + report->setProperty("inactiveDriveFactorComparisons", inactiveFactorComparisons); + report->setProperty("maximumInactiveDriveFactorDifference", maximumInactiveFactorDifference); + report->setProperty("elapsedMilliseconds", juce::Time::getMillisecondCounterHiRes() - started); + add(checks, "nam_fuzz_alias_measurement_contracts", + integrityPassed && controlsPassed && inactiveFactorPassed ? "pass" : "fail", + "Only measurement controls, finite nonzero processing without safety fallback, exact coverage, and independence of Fuzz-only from inactive Drive oversampling after reported integer PDC are asserted. Fuzz alias audibility and high-rate residual size are not quality gates.", reportValue); + add(checks, "nam_fuzz_alias_and_cascade_residuals", "diagnostic_only", + "Inspect the per-case occupied/unoccupied complex residuals, factor comparisons and reference convergence in nam_fuzz_alias_measurement_contracts. Audibility and preferred tone require exact-artifact audition."); + return checks; + } + +private: + static constexpr int occupiedStride = 11; + static constexpr float measurementOutputTrimDb = -18.0f; + static constexpr std::array frequencies { 5500.0, 9900.0, 13200.0, 18700.0 }; + static constexpr std::array amplitudes { 0.040, 0.035, 0.030, 0.025 }; + static constexpr std::array phases { 0.17, 1.13, 2.41, -0.71 }; + static int distortionModeFor(int topology) + { + // Topology order is Off/Crunch/Heavy/Extreme. Persisted Rack values + // retain the established Heavy0/Extreme1/Crunch2 contract. + constexpr std::array modes { -1, 2, 0, 1 }; + return modes[static_cast(topology & 3)]; + } + struct Rendered + { + std::vector audio; + int latencySamples = 0; + bool integrityPassed = false; + juce::var diagnostics; + }; + + static double signal(double time) + { + double value = 0.0; + for (size_t tone = 0; tone < frequencies.size(); ++tone) + value += amplitudes[tone] * std::sin( + juce::MathConstants::twoPi * frequencies[tone] * time + phases[tone]); + return value; + } + static std::vector makeSource(double rate, int factor) + { + const int count = static_cast(std::round(rate * 0.28)) * factor + + NAMNonlinearAliasAnalysis::decimatorTapCount - 1; + std::vector result(static_cast(count)); + for (int sample = 0; sample < count; ++sample) + result[static_cast(sample)] = static_cast(signal( + (sample - NAMNonlinearAliasAnalysis::decimatorHalfLength) / (rate * factor))); + return result; + } + static Rendered render(double rate, int factor, const std::vector& source, + bool bass, int voice, int topology) + { + const auto rack = std::make_unique(); + rack->setRoutedInputChannelCount(1); + rack->instrumentProfile.store(bass ? 1.0f : 0.0f); + rack->calibrationReferenceDbu.store(12.0f); + rack->inputTrimDb.store(0.0f); rack->outputTrimDb.store(measurementOutputTrimDb); + rack->gateThresholdDb.store(-100.0f); rack->compressorEnabled.store(0.0f); + rack->octaverEnabled.store(0.0f); rack->fuzzEnabled.store(1.0f); + rack->fuzzVoice.store(static_cast(voice)); rack->fuzzDrive.store(0.82f); + rack->fuzzTone.store(0.75f); rack->fuzzLevelDb.store(0.0f); rack->fuzzCleanMix.store(0.0f); + rack->preEqEnabled.store(0.0f); rack->setEmbeddedDriveOversamplingFactor(factor); + rack->precisionDriveEnabled.store((topology & 4) != 0 ? 1.0f : 0.0f); + rack->precisionDriveDrive.store(0.82f); rack->precisionDriveBright.store(1.0f); + rack->precisionDriveAttack.store(0.18f); rack->precisionDriveGate.store(0.0f); + rack->precisionDriveVolumeDb.store(0.0f); + const int distortion = distortionModeFor(topology); + rack->chaosEnabled.store(distortion >= 0 ? 1.0f : 0.0f); + rack->chaosMode.store(static_cast(juce::jmax(0, distortion))); + rack->chaosDrive.store(0.82f); rack->chaosTone.store(0.58f); rack->chaosWeight.store(0.5f); + rack->chaosGate.store(0.0f); rack->chaosMix.store(1.0f); rack->chaosLevelDb.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); + rack->setPlayConfigDetails(1, 1, rate, 128); rack->prepareToPlay(rate, 128); + Rendered result; + result.latencySamples = rack->getLatencySamples(); + result.audio = source; + juce::MidiBuffer midi; + int nonFinite = 0; + float peak = 0.0f; + for (int start = 0; start < static_cast(result.audio.size()); start += 128) + { + const int count = juce::jmin(128, static_cast(result.audio.size()) - start); + float* pointer = result.audio.data() + start; + juce::AudioBuffer block(&pointer, 1, count); + rack->processBlock(block, midi); + for (int sample = 0; sample < count; ++sample) + if (!std::isfinite(pointer[sample])) + { + ++nonFinite; + pointer[sample] = 0.0f; + } + else + peak = juce::jmax(peak, std::abs(pointer[sample])); + } + const auto native = rack->getDiagnosticState(); + auto* diagnostic = new juce::DynamicObject(); + result.diagnostics = juce::var(diagnostic); + diagnostic->setProperty("sampleRate", rate); + diagnostic->setProperty("measurementOutputTrimDb", measurementOutputTrimDb); + diagnostic->setProperty("latencySamples", result.latencySamples); + diagnostic->setProperty("nonFiniteSamples", nonFinite); + diagnostic->setProperty("samplePeak", peak); + diagnostic->setProperty("rackMaximumRawOutputPeakLinear", + native.getProperty("rackMaximumRawOutputPeakLinear", -1.0)); + result.integrityPassed = nonFinite == 0 && peak > 0.0f; + for (const char* name : { "rackOutputSafetyGuardHitCount", "audioThreadResizeAvoidedCount", + "oversizeBypassCount", "realtimeSafetyBypassCount", "modelProcessFailCount", + "rackOutputEmergencyMuteTripCount", "inputNonFiniteSampleCount" }) + { + const auto count = static_cast(native.getProperty( + juce::Identifier(name), static_cast(-1))); + diagnostic->setProperty(name, count); + // Any guard changes the nonlinear transfer under measurement. + result.integrityPassed = result.integrityPassed && count == 0; + } + diagnostic->setProperty("passed", result.integrityPassed); + rack->releaseResources(); + return result; + } + + static juce::var describe(double rate, bool bass, int voice, int topology, int factor, + const NAMNonlinearAliasAnalysis::AlignedResidual& residual) + { + auto* value = new juce::DynamicObject(); + value->setProperty("sampleRate", rate); value->setProperty("bassProfile", bass); + value->setProperty("fuzzVoice", voice); value->setProperty("topology", topology); + value->setProperty("driveEnabled", (topology & 4) != 0); + value->setProperty("distortionMode", distortionModeFor(topology)); + value->setProperty("driveOversamplingFactor", factor); + value->setProperty("unoccupiedResidualRatio", residual.unoccupiedRatio); + value->setProperty("occupiedResidualRatio", residual.occupiedRatio); + value->setProperty("integerAdvanceSamples", residual.integerAdvance); + value->setProperty("fractionalAdvanceSamples", residual.fractionalAdvance); + value->setProperty("occupiedBins", residual.occupiedBins); + value->setProperty("unoccupiedBins", residual.unoccupiedBins); + value->setProperty("finiteResidual", residual.finite); + value->setProperty("interpretation", "diagnostic_only"); + return juce::var(value); + } + + static void addPeriodDiagnostics(juce::var& row, const std::vector& device, + const std::vector& reference, double rate, + const NAMNonlinearAliasAnalysis::AlignedResidual& residual) + { + const int period = juce::jmax(32, static_cast(std::round( + rate * NAMNonlinearAliasAnalysis::analysisPeriodSeconds))); + const int available = static_cast(juce::jmin(device.size(), reference.size())); + const int referenceStart = available - std::abs(residual.integerAdvance) - 8 - period; + const auto mismatch = [period] (const std::vector& audio, int start) + { + if (start < period || start + period > static_cast(audio.size())) + return -1.0; + double power = 0.0, error = 0.0; + for (int sample = 0; sample < period; ++sample) + { + const double current = audio[static_cast(start + sample)]; + const double previous = audio[static_cast(start + sample - period)]; + power += current * current; + error += (current - previous) * (current - previous); + } + return std::sqrt(error / juce::jmax(1.0e-30, power)); + }; + row.getDynamicObject()->setProperty("deviceConsecutivePeriodRelativeDifference", + mismatch(device, referenceStart + residual.integerAdvance)); + row.getDynamicObject()->setProperty("referenceConsecutivePeriodRelativeDifference", + mismatch(reference, referenceStart)); + } + + static juce::var measurementControls(double rate, const std::vector& high, + const std::vector& base, + const NAMNonlinearAliasAnalysis& analysis, bool& passed) + { + std::vector linear(high.size()), nonlinear(high.size()), broken(base.size()); + for (size_t sample = 0; sample < high.size(); ++sample) + { + linear[sample] = high[sample] * 0.731f; + nonlinear[sample] = std::tanh(64.0f * high[sample]); + } + for (size_t sample = 0; sample < base.size(); ++sample) + broken[sample] = std::tanh(64.0f * base[sample]); + const auto linearReference = analysis.decimate(linear); + double energy = 0.0, errorEnergy = 0.0; + for (size_t sample = 0; sample < base.size(); ++sample) + { + const double expected = static_cast(base[sample]) * 0.731; + const double difference = linearReference[sample] - expected; + energy += expected * expected; errorEnergy += difference * difference; + } + const double linearError = std::sqrt(errorEnergy / juce::jmax(1.0e-30, energy)); + const auto brokenResidual = NAMNonlinearAliasAnalysis::measureAlignedResidual( + broken, analysis.decimate(nonlinear), rate, 0.0, occupiedStride); + + // An independent analytic source checks the alignment sign, bounded + // search and absence of gain fitting. The known fractional delay is on + // the search grid; a deliberate10% level error must remain measurable. + std::vector reference(base.size()), delayed(base.size()), wrongGain(base.size()); + for (size_t sample = 0; sample < base.size(); ++sample) + { + const auto position = static_cast(sample); + reference[sample] = static_cast(0.731 * signal(position / rate)); + delayed[sample] = static_cast(0.731 * signal((position - 5.25) / rate)); + wrongGain[sample] = delayed[sample] * 1.1f; + } + const auto aligned = NAMNonlinearAliasAnalysis::measureAlignedResidual( + delayed, reference, rate, 5.25, occupiedStride); + const auto gainError = NAMNonlinearAliasAnalysis::measureAlignedResidual( + wrongGain, reference, rate, 5.25, occupiedStride); + const bool valid = linearError <= 2.0e-6 && brokenResidual.finite + && brokenResidual.unoccupiedRatio >= 0.0001 + && brokenResidual.unoccupiedRatio >= linearError * 1000.0 + && aligned.finite && aligned.integerAdvance == 5 && aligned.fractionalAdvance == 0.25 + && aligned.occupiedRatio <= 1.0e-5 && aligned.unoccupiedRatio <= 1.0e-5 + && gainError.finite && gainError.occupiedRatio > 0.099 && gainError.occupiedRatio < 0.101; + passed = passed && valid; + auto* value = new juce::DynamicObject(); + value->setProperty("sampleRate", rate); value->setProperty("passed", valid); + value->setProperty("linearCommutationRelativeError", linearError); + value->setProperty("brokenBaseRateTanhUnoccupiedResidual", brokenResidual.unoccupiedRatio); + value->setProperty("knownDelaySamples", 5.25); + value->setProperty("recoveredIntegerAdvance", aligned.integerAdvance); + value->setProperty("recoveredFractionalAdvance", aligned.fractionalAdvance); + value->setProperty("knownGainDelayOccupiedResidual", aligned.occupiedRatio); + value->setProperty("knownGainDelayUnoccupiedResidual", aligned.unoccupiedRatio); + value->setProperty("deliberateTenPercentGainErrorOccupiedResidual", gainError.occupiedRatio); + return juce::var(value); + } + + static void add(juce::Array& checks, const char* id, const char* status, + const char* detail, const juce::var& value = {}) + { + auto* result = new juce::DynamicObject(); + result->setProperty("id", id); result->setProperty("status", status); + result->setProperty("detail", detail); + if (!value.isVoid()) result->setProperty("value", value); + checks.add(juce::var(result)); + } +}; diff --git a/Source/NAMFuzzInteractionRegression.h b/Source/NAMFuzzInteractionRegression.h new file mode 100644 index 0000000..a288ecd --- /dev/null +++ b/Source/NAMFuzzInteractionRegression.h @@ -0,0 +1,774 @@ +#pragma once + +#include "NAMFuzz.h" +#include "NAMPolyOctaver.h" +#include "BuiltInEffects2.h" +#include +#include +#include +#include +#include +#include + +// Opt-in, local QA only. Never read by the production renderer. No sound-quality +// pass is inferred from the measurements or the existence of audition files. +class NAMFuzzInteractionRegression final +{ +public: + static juce::Array run() + { + juce::Array checks; + const auto requested = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_AUDIT_DIR", {}); + if (requested.isEmpty()) + { + add(checks, "nam_fuzz_interaction_audit", "not_asserted", "Opt-in sweep/listening pack not requested; set OPENSTUDIO_NAM_FUZZ_AUDIT_DIR to an isolated QA output root."); + return checks; + } + const auto started = juce::Time::getMillisecondCounterHiRes(); + const auto directory = juce::File::getCurrentWorkingDirectory().getChildFile(requested) + .getChildFile("fuzz-" + juce::Uuid().toString().substring(0, 8)); + if (!directory.createDirectory().wasOk()) + { + add(checks, "nam_fuzz_interaction_audit", "fail", "Could not create the requested isolated output directory."); + return checks; + } + bool structuralPassed = true, writesPassed = true; + auto* report = new juce::DynamicObject(); + const juce::var reportValue(report); + report->setProperty("schemaVersion", 1); + report->setProperty("sampleRate", rate); + report->setProperty("directory", directory.getFullPathName()); + report->setProperty("route", "The sweep and listening arrays cover the exact NAMPolyOctaver production voices/mixer followed by NAMFuzz, without amp/cab. Optional fullChainListening rows separately use the production NAM Rack and explicit amp/IR resources; their route and diagnostics describe that coverage."); + report->setProperty("outputProcessing", "Native levels retained in IEEE float32 WAVs, including samples above unity; no export gain matching, added limiter, normalization or clipping. Full-chain exports retain production rack safety processing, recorded separately in each row. Listening-player/device clipping remains possible."); + report->setProperty("soundQuality", "not_asserted"); + report->setProperty("aliasingIntermodulationLoudnessCancellationAndGating", "diagnostic_only"); + report->setProperty("coverage", "144 short cases: Guitar/Bass profiles x mono/chord x -48/-18/-1 dBFS input peaks x three fuzz voices x four route/control corners. This is a bounded boundary sample, not every combination or a universal no-clash guarantee."); + report->setProperty("sourceScope", "Recorded guitar notes/arrangements where present. A Bass profile does not make a guitar recording a bass DI. Unavailable bass DI is explicitly synthetic."); + report->setProperty("sweep", sweep(structuralPassed)); + report->setProperty("lowBassDownOctave", lowBass()); + report->setProperty("cleanBlendFrequencyDiagnostics", cleanFrequency(structuralPassed)); + report->setProperty("voiceRmsBalance", voiceBalance(structuralPassed)); + report->setProperty("gatedQuietTail", gatedTail(structuralPassed)); + report->setProperty("listening", listening(directory, writesPassed)); + report->setProperty("fullChainListening", fullChainListening(directory, structuralPassed, writesPassed, checks)); + report->setProperty("structuralPassed", structuralPassed); + report->setProperty("filesWritten", writesPassed); + report->setProperty("elapsedMilliseconds", juce::Time::getMillisecondCounterHiRes() - started); + const auto manifest = directory.getChildFile("manifest.json"); + writesPassed = manifest.replaceWithText(juce::JSON::toString(reportValue, true)) && writesPassed; + add(checks, "nam_fuzz_interaction_structural", structuralPassed && writesPassed ? "pass" : "fail", + "Opt-in composition checks finite samples, silent-right isolation, fixed-control production-mix parity and callback partition invariance, and verifies audition file writes. Sound quality is not asserted.", reportValue); + add(checks, "nam_fuzz_interaction_sound_measurements", "diagnostic_only", + "Peaks above unity, coherent blend interference, low-sub attenuation, gated tail windows, spectra and voice loudness are diagnostic observations requiring exact-artifact audition. See the manifest and unnormalized float WAVs.", manifest.getFullPathName()); + return checks; + } + +private: + inline static constexpr double rate = 48000.0; + struct Voices { juce::AudioBuffer down, up; }; + struct Material { juce::String name; juce::AudioBuffer audio; juce::var provenance; bool bass = false; }; + static void add(juce::Array& checks, const juce::String& id, const juce::String& status, + const juce::String& detail, const juce::var& value = {}) + { + auto* result = new juce::DynamicObject(); + result->setProperty("id", id); result->setProperty("status", status); result->setProperty("detail", detail); + if (!value.isVoid()) result->setProperty("value", value); + checks.add(juce::var(result)); + } + static juce::AudioBuffer synthetic(bool bass, bool chord, int count, float peak) + { + juce::AudioBuffer audio(2, count); audio.clear(); + const double fundamental = bass ? 30.8677063 : 82.4068892; + double maximum = 0.0; + for (int sample = 0; sample < count; ++sample) + { + const double time = static_cast(sample) / rate; + const double envelope = (1.0 - std::exp(-time / 0.002)) * std::exp(-time / 0.25); + double value = 0.0; + for (int note = 0; note < (chord ? 3 : 1); ++note) + for (int harmonic = 1; harmonic <= 5; ++harmonic) + value += envelope * std::sin(juce::MathConstants::twoPi * fundamental + * std::pow(2.0, (note == 1 ? 4.0 : note == 2 ? 7.0 : 0.0) / 12.0) * harmonic * time) / (harmonic * 2.0); + audio.setSample(0, sample, static_cast(value)); maximum = juce::jmax(maximum, std::abs(value)); + } + audio.applyGain(static_cast(peak / juce::jmax(1.0e-9, maximum))); + return audio; // Right stays exactly silent for coupling tests. + } + static Voices octaves(const juce::AudioBuffer& source, bool bass) + { + Voices result { juce::AudioBuffer(2, source.getNumSamples()), juce::AudioBuffer(2, source.getNumSamples()) }; + const auto octave = std::make_unique(); + octave->setInstrumentProfile(bass ? 1 : 0); octave->prepare(rate, 512); + const std::array input { source.getReadPointer(0), source.getReadPointer(1) }; + std::array down { result.down.getWritePointer(0), result.down.getWritePointer(1) }; + std::array up { result.up.getWritePointer(0), result.up.getWritePointer(1) }; + octave->processVoicesForTesting(input.data(), down.data(), up.data(), 2, source.getNumSamples()); + return result; + } + static juce::AudioBuffer mix(const juce::AudioBuffer& source, const Voices& voices, float direct, float down, float up) + { + juce::AudioBuffer audio(2,source.getNumSamples()); + // Same multiply/add order as NAMPolyOctaver::processBlock at fixed + // prepared levels, avoiding a vectorized mixer changing roundoff. + for (int channel = 0; channel < 2; ++channel) for(int sample=0;sample fuzz(const juce::AudioBuffer& source, NAMFuzz::Parameters controls,float excitationGain=1.0f) + { + juce::AudioBuffer audio; audio.makeCopyOf(source); NAMFuzz processor; processor.prepare(rate, controls); + const std::vector excitation(static_cast(audio.getNumSamples()),excitationGain); + processor.processBlock(audio,excitation.data()); return audio; + } + static juce::var parameters(const NAMFuzz::Parameters& control) + { + auto* object = new juce::DynamicObject(); object->setProperty("enabled", control.enabled); + object->setProperty("voice", control.voice); object->setProperty("drive", control.drive); + object->setProperty("tone", control.tone); object->setProperty("levelDb", control.levelDb); + object->setProperty("cleanMix", control.cleanMix); object->setProperty("bass", control.bass); return juce::var(object); + } + static double power(const juce::AudioBuffer& audio, int start = 0, int end = -1) + { + if (end < 0) + end = audio.getNumSamples(); + double sum = 0.0; + for (int sample = start; sample < end; ++sample) { const double value = audio.getSample(0, sample); sum += value * value; } + return sum / static_cast(juce::jmax(1, end - start)); + } + static double db(double value) { return 20.0 * std::log10(juce::jmax(1.0e-12, value)); } + static double difference(const juce::AudioBuffer& a, const juce::AudioBuffer& b) + { + double maximum = 0.0; + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < a.getNumSamples(); ++sample) + { const double delta = std::abs(static_cast(a.getSample(channel, sample)) - b.getSample(channel, sample)); if (!std::isfinite(delta)) return 1.0e30; maximum = juce::jmax(maximum, delta); } + return maximum; + } + static juce::var measure(const juce::AudioBuffer& audio, const juce::AudioBuffer& input, bool& structurallyFinite) + { + double peak = 0.0, sum = 0.0, right = 0.0; int overUnity = 0, gatedWindows = 0, activeWindows = 0; + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < audio.getNumSamples(); ++sample) + { + const double value = audio.getSample(channel, sample); + structurallyFinite = structurallyFinite && std::isfinite(value); + if (!std::isfinite(value)) continue; + peak = juce::jmax(peak, std::abs(value)); if (std::abs(value) > 1.0) ++overUnity; + if (channel == 0) sum += value; else right = juce::jmax(right, std::abs(value)); + } + for (int start = 0; start + 960 <= audio.getNumSamples(); start += 960) + if (power(input, start, start + 960) > 1.0e-9) + { ++activeWindows; if (power(audio, start, start + 960) < 1.0e-12) ++gatedWindows; } + auto* result = new juce::DynamicObject(); result->setProperty("samplePeak", peak); result->setProperty("samplePeakDbFS", db(peak)); + result->setProperty("samplesAboveUnity", overUnity); result->setProperty("rmsDbFS", db(std::sqrt(power(audio)))); + result->setProperty("meanLeft", sum / audio.getNumSamples()); result->setProperty("rightPeak", right); + result->setProperty("activeInput20msWindows", activeWindows); result->setProperty("outputBelowMinus120DbWindows", gatedWindows); + result->setProperty("windowGatingInterpretation", "diagnostic_only: quiet-window counts do not prove an audible defect or approved gating behavior."); + return juce::var(result); + } + static juce::var blendDiagnostic(const juce::AudioBuffer& wet, const juce::AudioBuffer& incoming) + { + double dot = 0.0; for (int sample = 0; sample < wet.getNumSamples(); ++sample) dot += static_cast(wet.getSample(0, sample)) * incoming.getSample(0, sample); + dot /= wet.getNumSamples(); const double wetPower = power(wet), cleanPower = power(incoming); + const double incoherent = 0.25 * (wetPower + cleanPower), coherent = juce::jmax(0.0, incoherent + 0.5 * dot); + auto* result = new juce::DynamicObject(); result->setProperty("wetIncomingCorrelation", dot / std::sqrt(juce::jmax(1.0e-30, wetPower * cleanPower))); + result->setProperty("halfBlendCoherentVsIncoherentDb", db(std::sqrt(coherent / juce::jmax(1.0e-30, incoherent)))); + result->setProperty("halfBlendPredictedRmsDbFS", db(std::sqrt(coherent))); + result->setProperty("cleanSource", "Pre-fuzz Direct+Down+Up sum, not the original DI. These are endpoint-coherence predictions under the original linear blend law, not actual measurements of a changed partial-blend implementation. See cleanBlendFrequencyDiagnostics for actual renders."); return juce::var(result); + } + static juce::var sweep(bool& passed) + { + juce::Array rows; + constexpr std::array, 4> routes {{{0.0f,1.0f,0.0f},{0.0f,0.0f,1.0f},{1.25f,0.0f,0.0f},{1.25f,1.0f,1.0f}}}; + for (const bool bass : {false, true}) for (const bool chord : {false, true}) for (const float level : {-48.0f,-18.0f,-1.0f}) + { + const auto source = synthetic(bass, chord, 19200, juce::Decibels::decibelsToGain(level)); const auto voices = octaves(source, bass); + for (int voice = 0; voice < 3; ++voice) for (int route = 0; route < 4; ++route) + { + const auto gains = routes[static_cast(route)]; const auto incoming = mix(source, voices, gains[0], gains[1], gains[2]); + const float excitationGain=excitationForLevels(gains[0],gains[1],gains[2]); + NAMFuzz::Parameters controls; controls.enabled = true; controls.bass = bass; controls.voice = voice; + controls.drive = route >= 2 ? 1.0f : 0.0f; controls.tone = route % 2 ? 1.0f : 0.0f; + controls.levelDb = route % 2 ? 12.0f : -18.0f; controls.cleanMix = route == 1 ? 0.25f : route == 2 ? 0.5f : 0.0f; + const auto output = fuzz(incoming, controls,excitationGain); auto* row = new juce::DynamicObject(); + row->setProperty("bass", bass); row->setProperty("chord", chord); row->setProperty("inputPeakDbFS", level); + row->setProperty("direct", gains[0]); row->setProperty("down", gains[1]); row->setProperty("up", gains[2]); + row->setProperty("excitationMetadataGain",excitationGain); + row->setProperty("fuzz", parameters(controls)); const auto metrics = measure(output, incoming, passed); row->setProperty("metrics", metrics); + passed = passed && static_cast(metrics.getProperty("rightPeak", 1.0)) == 0.0; + // Actual all-wet output at unity Level isolates branch coherence + // from ordinary crossfade/gain changes in the boundary matrix. + controls.levelDb = 0.0f; controls.cleanMix = 0.0f; + row->setProperty("blendAtUnityLevel", blendDiagnostic(fuzz(incoming, controls,excitationGain), incoming)); rows.add(juce::var(row)); + } + // Verify the cached raw-voice mix against the real production mixer + // and the combined stage under two callback partitions. + if (level == -18.0f && !chord) + { + auto production = [bass, &source] (int blockSize) + { + juce::AudioBuffer output; output.makeCopyOf(source); const auto octave = std::make_unique(); + octave->setInstrumentProfile(bass ? 1 : 0); octave->setLevels(1.25f,1.0f,1.0f); octave->prepare(rate,512); + NAMFuzz::Parameters control; control.enabled = true; control.bass = bass; NAMFuzz processor; processor.prepare(rate,control); + std::array excitation{}; + for (int start=0; start p {output.getWritePointer(0,start),output.getWritePointer(1,start)}; juce::AudioBuffer block(p.data(),2,juce::jmin(blockSize,output.getNumSamples()-start)); octave->processBlock(block,excitation.data()); processor.processBlock(block,excitation.data()); } + return output; + }; + NAMFuzz::Parameters control; control.enabled=true; control.bass=bass; + const auto reference=fuzz(mix(source,voices,1.25f,1.0f,1.0f),control,excitationForLevels(1.25f,1.0f,1.0f)), regular=production(512), irregular=production(7); + passed = passed && difference(reference,regular)<=1.0e-6 && difference(regular,irregular)==0.0; + } + } + return juce::var(rows); + } + static std::array phasor(const juce::AudioBuffer& audio, double frequency) + { + // Fit sin,cos,DC jointly rather than assuming an integer FFT bin. This + // avoids a window-projection bias at31/41Hz and15.43Hz octave down. + std::array,3> equations{}; + for(int sample=audio.getNumSamples()/2;sample::twoPi*frequency*sample/rate; + const std::array basis{std::sin(phase),std::cos(phase),1.0}; + for(size_t row=0;row<3;++row){for(size_t column=0;column<3;++column)equations[row][column]+=basis[row]*basis[column];equations[row][3]+=basis[row]*audio.getSample(0,sample);} + } + for(size_t pivot=0;pivot<3;++pivot) + { + size_t best=pivot;for(size_t row=pivot+1;row<3;++row)if(std::abs(equations[row][pivot])>std::abs(equations[best][pivot]))best=row; + if(std::abs(equations[best][pivot])<1.0e-12)return {}; + if(best!=pivot)std::swap(equations[best],equations[pivot]); + const double divisor=equations[pivot][pivot];for(size_t column=pivot;column<4;++column)equations[pivot][column]/=divisor; + for(size_t row=0;row<3;++row)if(row!=pivot){const double scale=equations[row][pivot];for(size_t column=pivot;column<4;++column)equations[row][column]-=scale*equations[pivot][column];} + } + return {equations[0][3],equations[1][3]}; + } + static double amplitude(const juce::AudioBuffer& audio,double frequency) + {const auto value=phasor(audio,frequency);return std::hypot(value[0],value[1]);} + static juce::var lowBass() + { + auto input=synthetic(true,false,96000,0.1f); const auto voices=octaves(input,true); juce::Array rows; + for(int voice=0;voice<3;++voice) + { + NAMFuzz::Parameters control;control.enabled=true;control.bass=true;control.voice=voice;const auto output=fuzz(voices.down,control); + bool finiteSamples=true;auto* row=new juce::DynamicObject();row->setProperty("voice",voice);row->setProperty("metrics",measure(output,voices.down,finiteSamples)); + row->setProperty("octaveOnly15_43HzAmplitude",amplitude(voices.down,15.43385315));row->setProperty("combined15_43HzAmplitude",amplitude(output,15.43385315)); + row->setProperty("interpretation","diagnostic_only: Bass B0 downshift is below the existing18Hz octave HP and sees fuzz12Hz input HP plus12Hz DC blocker. Synthetic decaying note; harmonic redistribution, gate closure and sub attenuation require audition.");rows.add(juce::var(row)); + } + return juce::var(rows); + } + static juce::var cleanFrequency(bool& passed) + { + juce::Array rows; + for(const bool bass:{false,true}) for(const double frequency:{31.0,41.0,82.0,220.0,1000.0,6000.0,10000.0}) for(int voice=0;voice<3;++voice) + { + juce::AudioBuffer input(2,24000);input.clear();for(int sample=0;sample(0.01*std::sin(juce::MathConstants::twoPi*frequency*sample/rate))); + NAMFuzz::Parameters control;control.enabled=true;control.bass=bass;control.voice=voice;control.tone=1.0f; + const auto wet=fuzz(input,control);control.cleanMix=1.0f;const auto clean=fuzz(input,control); + const int start=input.getNumSamples()/2; + auto* row=new juce::DynamicObject();row->setProperty("frequencyHz",frequency);row->setProperty("bass",bass);row->setProperty("voice",voice);row->setProperty("tone",1.0); + row->setProperty("drive",control.drive);row->setProperty("levelDb",0.0);row->setProperty("inputPeak",0.01); + row->setProperty("endpointCoherence",blendDiagnostic(wet,clean)); + const auto wetPhasor=phasor(wet,frequency),cleanPhasor=phasor(clean,frequency); + const double wetAmplitude=std::hypot(wetPhasor[0],wetPhasor[1]),cleanAmplitude=std::hypot(cleanPhasor[0],cleanPhasor[1]); + const double phaseCosine=(wetPhasor[0]*cleanPhasor[0]+wetPhasor[1]*cleanPhasor[1])/juce::jmax(1.0e-30,wetAmplitude*cleanAmplitude); + const float balancedBlend=static_cast(wetAmplitude/juce::jmax(1.0e-30,wetAmplitude+cleanAmplitude)); + control.cleanMix=balancedBlend;const auto balancedOutput=fuzz(input,control); + const double balancedIncoherent=(1.0-balancedBlend)*(1.0-balancedBlend)*wetAmplitude*wetAmplitude+balancedBlend*balancedBlend*cleanAmplitude*cleanAmplitude; + row->setProperty("steadyEndpointPhaseDifferenceDegrees",std::acos(juce::jlimit(-1.0,1.0,phaseCosine))*180.0/juce::MathConstants::pi); + row->setProperty("amplitudeBalancedCleanMix",balancedBlend); + row->setProperty("actualBalancedFundamentalVsIncoherentDb",db(amplitude(balancedOutput,frequency)/std::sqrt(juce::jmax(1.0e-30,balancedIncoherent)))); + row->setProperty("spectralEstimator","Least-squares sin/cos/DC fit over the second half of the rendered waveform; diagnostic only, not an alias or tone-quality proof."); + juce::Array blends; + for(const float blend:{0.0f,0.25f,0.5f,0.75f,1.0f}) + { + control.cleanMix=blend;const auto output=fuzz(input,control);const auto metrics=measure(output,input,passed); + passed=passed&&static_cast(metrics.getProperty("rightPeak",1.0))==0.0; + if(blend==1.0f)passed=passed&&difference(output,input)<=3.0e-8; + double referenceDifference=0.0; + for(int sample=start;sample(output.getSample(0,sample))-((1.0-blend)*wet.getSample(0,sample)+blend*clean.getSample(0,sample)))); + const double incoherent=(1.0-blend)*(1.0-blend)*power(wet,start)+blend*blend*power(clean,start); + auto* actual=new juce::DynamicObject();actual->setProperty("cleanMix",blend);actual->setProperty("metrics",metrics); + actual->setProperty("lateRmsDbFS",db(std::sqrt(power(output,start)))); + actual->setProperty("actualVsIncoherentEndpointPowerDb",db(std::sqrt(power(output,start)/juce::jmax(1.0e-30,incoherent)))); + actual->setProperty("maximumDifferenceFromLinearEndpointSum",referenceDifference); + passed=passed&&referenceDifference<=1.0e-6; + actual->setProperty("fundamentalAmplitude",amplitude(output,frequency));blends.add(juce::var(actual)); + } + row->setProperty("actualRenderedBlends",juce::var(blends)); + row->setProperty("interpretation","diagnostic_only: actual partial-blend spectral/power output, without loudness matching. The retained linear parallel blend law, Clean100% unity-Level parity, finiteness and silent-right isolation are structural assertions; cancellation depth is not a sound-quality pass.");rows.add(juce::var(row)); + } + return juce::var(rows); + } + + static juce::var voiceBalance(bool& passed) + { + juce::Array rows; + for(int material=0;material<3;++material) + { + const bool bass=material==2;const auto input=synthetic(bass,material==1,24000,0.126f); + for(const float drive:{0.0f,0.5f,1.0f})for(const float tone:{0.0f,0.5f,1.0f}) + { + juce::Array voices;double minimum=1.0e30,maximum=-1.0e30; + for(int voice=0;voice<3;++voice) + { + NAMFuzz::Parameters control;control.enabled=true;control.bass=bass;control.voice=voice;control.drive=drive;control.tone=tone; + const auto output=fuzz(input,control);const auto metrics=measure(output,input,passed); + passed=passed&&static_cast(metrics.getProperty("rightPeak",1.0))==0.0; + const double rms=db(std::sqrt(power(output)));minimum=juce::jmin(minimum,rms);maximum=juce::jmax(maximum,rms); + auto* voiceRow=new juce::DynamicObject();voiceRow->setProperty("voice",voice);voiceRow->setProperty("metrics",metrics);voices.add(juce::var(voiceRow)); + } + auto* row=new juce::DynamicObject();row->setProperty("material",material==0?"synthetic-guitar-mono":material==1?"synthetic-guitar-chord":"synthetic-bass-B0"); + row->setProperty("inputPeak",0.126);row->setProperty("drive",drive);row->setProperty("tone",tone);row->setProperty("levelDb",0.0);row->setProperty("cleanMix",0.0); + row->setProperty("voices",juce::var(voices));row->setProperty("voiceRmsSpreadDb",maximum-minimum); + row->setProperty("interpretation","diagnostic_only: voice RMS differences across material/drive/tone are measured at identical native Level. No material was normalized after rendering; intentional tonal differences remain possible.");rows.add(juce::var(row)); + } + } + return juce::var(rows); + } + + static juce::var gatedTail(bool& passed) + { + juce::Array rows; + // Known input segments, rather than a short peak-normalized decay, + // expose late quiet-note retention, silence retirement and re-entry. + constexpr std::array peaks {0.03f,0.006f,0.003f,0.0015f,0.0007f,0.0f,0.0015f,0.03f}; + constexpr int segmentSamples=12000; + for(const bool bass:{false,true})for(const float drive:{0.0f,0.5f,1.0f}) + { + juce::AudioBuffer input(2,segmentSamples*static_cast(peaks.size()));input.clear(); + for(int sample=0;sample(segment)]*static_cast(std::sin(juce::MathConstants::twoPi*frequency*time)));} + input.copyFrom(1,0,input,0,0,input.getNumSamples()); + NAMFuzz::Parameters control;control.enabled=true;control.voice=2;control.bass=bass;control.drive=drive; + const auto regular=fuzz(input,control); + juce::AudioBuffer irregular;irregular.makeCopyOf(input);NAMFuzz processor;processor.prepare(rate,control); + for(int start=0;start pointers{irregular.getWritePointer(0,start),irregular.getWritePointer(1,start)};juce::AudioBuffer block(pointers.data(),2,juce::jmin(31,irregular.getNumSamples()-start));processor.processBlock(block);} + const double partitionDifference=difference(regular,irregular);passed=passed&&partitionDifference==0.0; + bool stereoParity=true;for(int sample=0;sample segments; + for(size_t segment=0;segment(segment)*segmentSamples;auto* measured=new juce::DynamicObject(); + measured->setProperty("inputPeak",peaks[segment]);measured->setProperty("startSeconds",begin/rate); + measured->setProperty("inputLateRmsDbFS",db(std::sqrt(power(input,begin+segmentSamples/2,begin+segmentSamples)))); + measured->setProperty("outputLateRmsDbFS",db(std::sqrt(power(regular,begin+segmentSamples/2,begin+segmentSamples)))); + measured->setProperty("kind",segment==5?"silence":segment==6?"quiet-reentry":segment==7?"loud-reentry":"descending-tail");segments.add(juce::var(measured)); + } + auto* row=new juce::DynamicObject();row->setProperty("bass",bass);row->setProperty("drive",drive);row->setProperty("segments",juce::var(segments));row->setProperty("metrics",metrics); + row->setProperty("maximumPartitionDifference",partitionDifference);row->setProperty("identicalStereoParity",stereoParity); + row->setProperty("interpretation","Late envelope and re-entry values are diagnostic_only. Exact callback partition and identical-stereo parity are asserted. These output measurements alone do not prove hysteresis without gate-state evidence.");rows.add(juce::var(row)); + } + return juce::var(rows); + } + static bool load(const juce::File& file, juce::AudioBuffer& audio, juce::var& provenance) + { + juce::AudioFormatManager formats;formats.registerBasicFormats();std::unique_ptr reader(formats.createReaderFor(file)); + if(!reader||reader->sampleRate<8000.0||reader->sampleRate>384000.0)return false; + const int inputCount=static_cast(std::min(reader->lengthInSamples,static_cast(reader->sampleRate*6.0))); + if (inputCount < 2) + return false; + juce::AudioBuffer decoded(2, inputCount); + decoded.clear(); + if (!reader->read(&decoded, 0, inputCount, 0, true, true)) + return false; + const int count=static_cast(inputCount*rate/reader->sampleRate);audio.setSize(2,count); + for(int sample=0;samplesampleRate/rate;const int index=juce::jmin(inputCount-1,static_cast(position));const float fraction=static_cast(position-index); + for(int channel=0;channel<2;++channel){const int sourceChannel=reader->numChannels==1?0:channel;audio.setSample(channel,sample,decoded.getSample(sourceChannel,index)*(1.0f-fraction)+decoded.getSample(sourceChannel,juce::jmin(index+1,inputCount-1))*fraction);}} + auto* source=new juce::DynamicObject();source->setProperty("path",file.getFullPathName());source->setProperty("sha256",juce::SHA256(file).toHexString()); + source->setProperty("sourceSampleRate",reader->sampleRate);source->setProperty("sourceChannels",static_cast(reader->numChannels)); + source->setProperty("preprocessing","First at most6seconds; nativeJUCE decoding, linear resampling to48k only if needed, mono duplicated to stereo; no gain normalization or fades.");provenance=juce::var(source);return true; + } + static bool write(const juce::File& path,const juce::AudioBuffer& audio) + { + std::unique_ptr stream=path.createOutputStream();if(!stream)return false;juce::WavAudioFormat format; + auto writer=format.createWriterFor(stream,juce::AudioFormatWriterOptions().withSampleRate(rate).withNumChannels(2).withBitsPerSample(32).withSampleFormat(juce::AudioFormatWriterOptions::SampleFormat::floatingPoint)); + return writer&&writer->writeFromAudioSampleBuffer(audio,0,audio.getNumSamples()); + } + static std::vector materials(bool& writesPassed) + { + std::vector materials;const auto root=juce::File::getCurrentWorkingDirectory(); + const auto overrideInput=juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_AUDIT_INPUT",{}); + for(const char* name:{"recorded-guitar-low-notes","recorded-guitar-layered-chord"}) + { + Material material;material.name=overrideInput.isNotEmpty()?"recorded-guitar-override":name; + const auto file=root.getChildFile(overrideInput.isNotEmpty()?overrideInput:"output/reverb-recorded/sources/"+juce::String(name)+".wav"); + if(load(file,material.audio,material.provenance)){material.provenance.getDynamicObject()->setProperty("qualification",overrideInput.isNotEmpty()?"User-selected existing audio; recording-chain/DI provenance not independently verified.":"Existing arranged CC0FreePats raw electric-guitar notes. See output/reverb-recorded/sources/manifest.json for original note hashes, explicit source gains and fades. Not a continuous live performance.");materials.push_back(std::move(material));} + else if(overrideInput.isNotEmpty())writesPassed=false; + if(overrideInput.isNotEmpty())break; + } + if(materials.empty()) + { + Material material;material.name="recorded-guitar-E2"; + if(load(root.getChildFile("resources/test_fixtures/guitar_di/EGuitarFSBS-direct SFZ+FLAC-20220911/samples/bridge/E2_s1_01.flac"),material.audio,material.provenance)) + {material.provenance.getDynamicObject()->setProperty("qualification","Existing CC0FreePats raw direct Fender guitar E2note; bank README/LICENSE provide provenance. One plucked note, not a riff.");materials.push_back(std::move(material));} + } + const auto bassInput=juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_AUDIT_BASS_INPUT",{}); + Material bass;bass.bass=true;bass.name=bassInput.isEmpty()?"synthetic-bass-B0-decay":"bass-audio-override"; + if(bassInput.isNotEmpty()) + {if(!load(root.getChildFile(bassInput),bass.audio,bass.provenance))writesPassed=false;} + else + {bass.audio=synthetic(true,false,192000,0.18f);bass.audio.copyFrom(1,0,bass.audio,0,0,bass.audio.getNumSamples());auto* origin=new juce::DynamicObject();origin->setProperty("qualification","Explicitly synthetic five-harmonic B0decay, peak0.18, attack2ms/decay250ms; no verified real bass DI found. Not a recorded bass.");bass.provenance=juce::var(origin);} + if(bass.audio.getNumSamples()>0)materials.push_back(std::move(bass)); + if(materials.size()==1&&materials.front().bass)writesPassed=false; + for (auto& material : materials) + { + juce::MemoryBlock samples; + for (int channel = 0; channel < material.audio.getNumChannels(); ++channel) + samples.append(material.audio.getReadPointer(channel), + static_cast(material.audio.getNumSamples()) * sizeof(float)); + material.provenance.getDynamicObject()->setProperty("renderedInputSha256", juce::SHA256(samples).toHexString()); + } + return materials; + } + static juce::var listening(const juce::File& directory,bool& writesPassed) + { + const auto sources = materials(writesPassed); + juce::Array entries; + for(const auto& material:sources) + { + const auto voices=octaves(material.audio,material.bass); + const auto combined=mix(material.audio,voices,1.0f,0.32f,0.18f); + for(int variant=0;variant<11;++variant) + { + NAMFuzz::Parameters control;control.enabled=variant>=2;control.bass=material.bass;control.voice=variant>=8?variant-8:variant>=5?variant-5:juce::jmax(0,variant-2); + control.cleanMix=variant>=8?0.5f:0.0f; + control.levelDb=control.enabled?-6.0f:0.0f; + const bool octave=variant==1||variant>=5;const auto& incoming=octave?combined:material.audio; + const float excitationGain=octave?excitationForLevels(1.0f,0.32f,0.18f):1.0f;const auto output=fuzz(incoming,control,excitationGain); + const juce::String voiceName=control.voice==0?"vintage":control.voice==1?"sustain":"gated"; + const auto label=variant==0?juce::String("dry"):variant==1?juce::String("octave-only"):juce::String(variant>=8?"combined-clean50-":variant>=5?"combined-":"fuzz-only-")+voiceName; + const auto file=directory.getChildFile(material.name+"-"+label+".wav");writesPassed=write(file,output)&&writesPassed; + auto* row=new juce::DynamicObject();row->setProperty("material",material.name);row->setProperty("file",file.getFileName());row->setProperty("source",material.provenance); + row->setProperty("seconds",output.getNumSamples()/rate);row->setProperty("bassProfile",material.bass);row->setProperty("direct",1.0);row->setProperty("down",octave?0.32:0.0);row->setProperty("up",octave?0.18:0.0); + row->setProperty("excitationMetadataGain",excitationGain); + row->setProperty("fuzz",parameters(control));bool finiteSamples=true;row->setProperty("metrics",measure(output,incoming,finiteSamples));writesPassed=writesPassed&&finiteSamples;entries.add(juce::var(row)); + } + } + return juce::var(entries); + } + + static juce::var fullChainListening(const juce::File& directory, bool& structuralPassed, + bool& writesPassed, juce::Array& checks) + { + juce::Array entries; + const bool extended = juce::SystemStats::getEnvironmentVariable( + "OPENSTUDIO_NAM_FUZZ_EXTENDED", {}) == "1"; + const auto ampPath = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_AUDIT_AMP", {}); + const auto cabPath = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_AUDIT_CAB", {}); + if (ampPath.isEmpty() && cabPath.isEmpty()) + { + add(checks, "nam_fuzz_full_chain_resources", extended ? "fail" : "not_asserted", + "Full-chain listening was not requested. Supply both OPENSTUDIO_NAM_FUZZ_AUDIT_AMP and OPENSTUDIO_NAM_FUZZ_AUDIT_CAB for an explicit local NAM amp and cabinet IR; no substitute rig is invented."); + structuralPassed = structuralPassed && !extended; + return juce::var(entries); + } + const auto root = juce::File::getCurrentWorkingDirectory(); + const auto amp = root.getChildFile(ampPath), cab = root.getChildFile(cabPath); + const auto rack = std::make_unique(); + const bool ampLoaded = ampPath.isNotEmpty() && amp.existsAsFile() + && rack->loadAmpModel(amp.getFullPathName()) && rack->hasAmpModel(); + const bool cabLoaded = cabPath.isNotEmpty() && cab.existsAsFile() + && rack->loadCabIR(cab.getFullPathName()) && rack->hasCabIR(); + const bool resourcesPassed = ampLoaded && cabLoaded && !rack->ampModelIncludesCab(); + auto* resources = new juce::DynamicObject(); + resources->setProperty("ampPath", amp.getFullPathName()); + resources->setProperty("cabPath", cab.getFullPathName()); + resources->setProperty("ampLoaded", ampLoaded); + resources->setProperty("cabLoaded", cabLoaded); + resources->setProperty("ampIncludesCab", rack->ampModelIncludesCab()); + resources->setProperty("ampCaptureType", rack->getAmpCaptureType()); + resources->setProperty("ampMetadataCaptureType", rack->getAmpMetadataCaptureType()); + resources->setProperty("ampMetadataName", rack->getAmpMetadataName()); + resources->setProperty("ampMetadataGearMake", rack->getAmpMetadataGearMake()); + resources->setProperty("ampMetadataGearModel", rack->getAmpMetadataGearModel()); + resources->setProperty("fixtureQualification", "Explicit existing local files, successful native loads and hashes; capture/IR authoring provenance is not independently certified by this test."); + if (amp.existsAsFile()) resources->setProperty("ampSha256", juce::SHA256(amp).toHexString()); + if (cab.existsAsFile()) resources->setProperty("cabSha256", juce::SHA256(cab).toHexString()); + add(checks, "nam_fuzz_full_chain_resources", resourcesPassed ? "pass" : "fail", + "Requested full-chain coverage requires a loaded real NAM amp and cabinet IR. Amp+cab captures are rejected to avoid silently doubling cabinet filtering.", juce::var(resources)); + structuralPassed = structuralPassed && resourcesPassed; + if (!resourcesPassed) return juce::var(entries); + + const auto sources = materials(writesPassed); + bool fullChainPassed = true; + juce::int64 ordinaryOutputGuardHits = 0; + const std::array stageNames {{ "rig-only", "fuzz", "drive", "fuzz-drive", + "distortion", "fuzz-distortion", "drive-distortion", "fuzz-drive-distortion" }}; + struct ChainCase + { + int mask = 7, voice = 0, distortionMode = 0; + bool octave = true; + float cleanMix = 0.0f, referenceDbu = 12.0f; + int gateMask = 0, octavePresence = -1; + juce::String suffix, category = "topology"; + }; + const auto makeCases = [extended] (bool stress) + { + std::vector cases; + // Default iteration, settings and filenames remain the archived + // 16 cases per control set. Extended coverage adds the independent + // Octave power state and all three active Distortion modes. + for (int octave = 0; octave < (extended ? 2 : 1); ++octave) + for (int mask = 0; mask < 8; ++mask) + for (int voice = 0; voice < ((mask & 1) != 0 ? 3 : 1); ++voice) + for (int mode = 0; mode < (extended && (mask & 4) != 0 ? 3 : 1); ++mode) + { + ChainCase item; + item.mask = mask; + item.voice = voice; + item.octave = !extended || octave != 0; + item.distortionMode = extended ? mode : (stress ? 1 : 0); + if (extended) + item.suffix = "-extended-octave-" + juce::String(item.octave ? "on" : "off") + + "-dist-" + juce::String((mask & 4) != 0 ? mode : -1); + cases.push_back(std::move(item)); + } + if (!extended || stress) return cases; + const auto context = [] (const juce::String& name, int voice) + { + ChainCase item; + item.voice = voice; + item.distortionMode = voice; + item.category = name; + item.suffix = "-extended-" + name; + return item; + }; + for (int voice = 0; voice < 3; ++voice) + { + for (float cleanMix : { 0.25f, 0.5f, 0.75f, 1.0f }) + { + auto item = context("clean", voice); + item.cleanMix = cleanMix; + item.suffix += "-" + juce::String(juce::roundToInt(cleanMix * 100.0f)); + cases.push_back(std::move(item)); + } + for (float referenceDbu : { 6.0f, 18.0f, 24.0f }) + { + auto item = context("calibration", voice); + item.referenceDbu = referenceDbu; + item.suffix += "-" + juce::String(juce::roundToInt(referenceDbu)); + cases.push_back(std::move(item)); + } + for (int presence = 1; presence < 8; ++presence) + { + auto item = context("octave-presence", voice); + item.octavePresence = presence; + item.suffix += "-" + juce::String(presence); + cases.push_back(std::move(item)); + } + } + for (int mask = 1; mask < 8; ++mask) + { + auto item = context("gates", 2); + item.gateMask = mask; // Global, Drive, Distortion; Fuzz is Gated. + item.suffix += "-" + juce::String(mask); + cases.push_back(std::move(item)); + } + return cases; + }; + juce::StringArray writtenNames; + for (const auto& material : sources) + { + for (const bool stress : { false, true }) + { + for (const auto& testCase : makeCases(stress)) + { + const int mask = testCase.mask, voice = testCase.voice; + const bool fuzzEnabled = (mask & 1) != 0; + // Every value is installed before prepare/reset. Resource loading and + // all buffers are allocated off the simulated audio callback. + rack->setRoutedInputChannelCount(2); + rack->instrumentProfile.store(material.bass ? 1.0f : 0.0f); + rack->inputTrimDb.store(0.0f); rack->outputTrimDb.store(0.0f); + rack->calibrationReferenceDbu.store(testCase.referenceDbu); + rack->gateThresholdDb.store((testCase.gateMask & 1) != 0 ? -45.0f : -100.0f); + rack->compressorEnabled.store(0.0f); + rack->octaverEnabled.store(testCase.octave ? 1.0f : 0.0f); + rack->octaverDirectMix.store(testCase.octavePresence < 0 + ? (stress ? 1.25f : 1.0f) : ((testCase.octavePresence & 1) != 0 ? 1.0f : 0.0f)); + rack->octaverDownMix.store(testCase.octavePresence < 0 + ? (stress ? 1.0f : 0.32f) : ((testCase.octavePresence & 2) != 0 ? 0.5f : 0.0f)); + rack->octaverUpMix.store(testCase.octavePresence < 0 + ? (stress ? 1.0f : 0.18f) : ((testCase.octavePresence & 4) != 0 ? 0.5f : 0.0f)); + rack->fuzzEnabled.store(fuzzEnabled ? 1.0f : 0.0f); + rack->fuzzVoice.store(static_cast(voice)); + rack->fuzzDrive.store(stress ? 1.0f : 0.45f); + rack->fuzzTone.store(stress ? 0.75f : 0.5f); + rack->fuzzLevelDb.store(stress ? 6.0f : -6.0f); rack->fuzzCleanMix.store(testCase.cleanMix); + rack->preEqEnabled.store(0.0f); + rack->precisionDriveEnabled.store((mask & 2) != 0 ? 1.0f : 0.0f); + rack->precisionDriveVoice.store(0.0f); + rack->precisionDriveDrive.store(stress ? 1.0f : 0.15f); + rack->precisionDriveVolumeDb.store(stress ? 12.0f : 3.0f); + rack->precisionDriveBright.store(0.5f); rack->precisionDriveAttack.store(0.5f); + rack->precisionDriveGate.store((testCase.gateMask & 2) != 0 ? 0.35f : 0.0f); + rack->chaosEnabled.store((mask & 4) != 0 ? 1.0f : 0.0f); + rack->chaosMode.store(static_cast(testCase.distortionMode)); + rack->chaosDrive.store(stress ? 1.0f : 0.35f); + rack->chaosTone.store(stress ? 0.75f : 0.5f); + rack->chaosWeight.store(0.5f); rack->chaosMix.store(1.0f); + rack->chaosLevelDb.store(stress ? 6.0f : -6.0f); + rack->chaosGate.store((testCase.gateMask & 4) != 0 ? 0.35f : 0.0f); + rack->pedalMix.store(0.0f); rack->ampEnabled.store(1.0f); rack->ampMix.store(1.0f); + rack->ampGainDb.store(0.0f); rack->ampBoost.store(0.0f); rack->ampVoice.store(0.0f); + rack->ampOutputDb.store(0.0f); rack->eqEnabled.store(0.0f); + rack->setCabRequestedEnabled(true); + rack->cabRoomEnabled.store(0.0f); rack->cabDoublerEnabled.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); + rack->prepareToPlay(rate, 512); rack->reset(); + rack->resetModelSnapshotLockMissCount(); + + // Calibration probes retain equivalent represented physical + // input. This source transform is recorded separately from + // output gain matching, which is never applied here. + const float inputScale = juce::Decibels::decibelsToGain(12.0f - testCase.referenceDbu); + juce::AudioBuffer input; input.makeCopyOf(material.audio); + if (inputScale != 1.0f) input.applyGain(inputScale); + juce::AudioBuffer output; output.makeCopyOf(input); + juce::MidiBuffer midi; + juce::int64 firstAmpDspFrames = 0; + for (int start = 0; start < output.getNumSamples(); start += 512) + { + const int count = juce::jmin(512, output.getNumSamples() - start); + std::array pointers {{ output.getWritePointer(0, start), output.getWritePointer(1, start) }}; + juce::AudioBuffer block(pointers.data(), 2, count); + rack->processBlock(block, midi); + if (start == 0) + firstAmpDspFrames = static_cast(rack->getDiagnosticState().getProperty( + "lastAmpDspFrames", static_cast(-1))); + } + const juce::String voiceName = voice == 0 ? "vintage" : voice == 1 ? "sustain" : "gated"; + const auto file = directory.getChildFile(material.name + "-full-chain-" + + (stress ? "stress-" : "conservative-") + stageNames[static_cast(mask)] + + (fuzzEnabled ? "-" + voiceName : juce::String()) + testCase.suffix + ".wav"); + if (writtenNames.contains(file.getFileName())) fullChainPassed = false; + writtenNames.addIfNotAlreadyThere(file.getFileName()); + writesPassed = write(file, output) && writesPassed; + auto* row = new juce::DynamicObject(); + row->setProperty("file", file.getFileName()); row->setProperty("material", material.name); + row->setProperty("source", material.provenance); row->setProperty("resources", juce::var(resources)); + row->setProperty("seconds", output.getNumSamples() / rate); row->setProperty("bassProfile", material.bass); + row->setProperty("stageMask", mask); row->setProperty("stages", stageNames[static_cast(mask)]); + row->setProperty("fuzzVoice", voice); + row->setProperty("setting", stress ? "stress" : "conservative"); + row->setProperty("category", testCase.category); + row->setProperty("extendedMatrix", extended); + row->setProperty("octaveEnabled", testCase.octave); + row->setProperty("distortionMode", testCase.distortionMode); + row->setProperty("cleanMix", testCase.cleanMix); + row->setProperty("gateMask", testCase.gateMask); + row->setProperty("octavePresenceMask", testCase.octavePresence); + row->setProperty("calibrationReferenceDbu", testCase.referenceDbu); + row->setProperty("inputScaleForEquivalentPhysicalLevel", inputScale); + if (inputScale != 1.0f) + { + juce::MemoryBlock inputBytes; + for (int channel = 0; channel < input.getNumChannels(); ++channel) + inputBytes.append(input.getReadPointer(channel), + static_cast(input.getNumSamples()) * sizeof(float)); + row->setProperty("scaledInputSha256", juce::SHA256(inputBytes).toHexString()); + } + row->setProperty("direct", static_cast(rack->octaverDirectMix.load())); + row->setProperty("down", static_cast(rack->octaverDownMix.load())); + row->setProperty("up", static_cast(rack->octaverUpMix.load())); + // The native state is the complete reproducible control/resource + // contract, rather than a hand-maintained subset of rack knobs. + juce::MemoryBlock state; rack->getStateInformation(state); + const auto stateTree = juce::ValueTree::readFromData(state.getData(), state.getSize()); + row->setProperty("rackState", stateTree.createXml()->toString()); + row->setProperty("route", "Production Octave -> Fuzz -> PRE EQ bypass -> Drive -> Distortion -> NAM amp -> cabinet IR; stage power/modes and optional Global/Drive/Distortion gates are recorded in each row. Compressor, captured pedal, post EQ, room/doubler and time effects bypassed."); + row->setProperty("outputProcessing", "Exact native IEEE float32; no export normalization, gain matching or added limiter. Production rack safety processing remains active."); + bool finiteSamples = true; + const auto metrics = measure(output, input, finiteSamples); + row->setProperty("metrics", metrics); + const auto diagnostics = rack->getDiagnosticState(); + const auto diagnosticCount = [&diagnostics] (const char* id) + { + return static_cast(diagnostics.getProperty( + juce::Identifier(id), static_cast(-1))); + }; + auto* integrity = new juce::DynamicObject(); + const bool resourcesReadback = rack->hasAmpModel() && rack->hasCabIR() + && juce::File(rack->getAmpModelPath()) == amp + && juce::File(rack->getCabIRPath()) == cab + && !rack->ampModelIncludesCab() && rack->isCabRequestedEnabled() + && rack->cabEnabled.load() >= 0.5f; + const int expectedBlocks = (output.getNumSamples() + 511) / 512; + const auto finalAmpDspFrames = diagnosticCount("lastAmpDspFrames"); + // A valid tiny final callback can produce no model-rate + // frames. The first prepared callback is a second activity + // witness; final cumulative fault counters still own safety. + const bool ampExecuted = firstAmpDspFrames > 0 || finalAmpDspFrames > 0; + // Published by the live Rack only when Cab was active with + // an IR snapshot. Together with the prepared capacity and + // exact resource readback, this excludes a loaded-but-bypassed + // cabinet from establishing full-chain coverage. + const bool cabActive = static_cast(diagnostics.getProperty("cabRoomInputSourceAvailable", false)); + const bool processedExpectedBlocks = diagnosticCount("processedBlockCount") == expectedBlocks + && diagnosticCount("bufferCapacity") >= juce::jmin(512, output.getNumSamples()); + const bool nonzeroOutput = static_cast(metrics.getProperty("samplePeak", 0.0)) > 0.0; + const bool requiresNonzeroOutput = testCase.gateMask == 0; + bool noFallback = !static_cast(diagnostics.getProperty("realtimeDSPBlocked", true)) + && !static_cast(diagnostics.getProperty("ampModelFaulted", true)) + && !static_cast(diagnostics.getProperty("rackOutputEmergencyMuteActive", true)); + for (const char* id : { "modelProcessFailCount", "audioThreadResizeAvoidedCount", + "oversizeBypassCount", "realtimeSafetyBypassCount", "modelSnapshotLockMissCount", + "rackOutputEmergencyMuteTripCount", "inputNonFiniteSampleCount", "ampToneFilterRecoveryCount" }) + { + const auto count = diagnosticCount(id); + integrity->setProperty(id, count); + noFallback = noFallback && count == 0; + } + const auto guardHits = diagnosticCount("rackOutputSafetyGuardHitCount"); + if (guardHits >= 0) ordinaryOutputGuardHits += guardHits; + integrity->setProperty("rackOutputSafetyGuardHitCount", guardHits); + integrity->setProperty("ordinaryGuardInterpretation", "diagnostic_only: production output-guard activity is retained, not normalized away and not an audible clipping/tone gate."); + integrity->setProperty("resourcesReadbackPassed", resourcesReadback); + integrity->setProperty("ampExecuted", ampExecuted); + integrity->setProperty("externalCabActive", cabActive); + integrity->setProperty("firstAmpDspFrames", firstAmpDspFrames); + integrity->setProperty("finalAmpDspFrames", finalAmpDspFrames); + integrity->setProperty("processedBlockCount", diagnosticCount("processedBlockCount")); + integrity->setProperty("expectedBlockCount", expectedBlocks); + integrity->setProperty("bufferCapacity", diagnosticCount("bufferCapacity")); + integrity->setProperty("activeInputRoutingMode", diagnosticCount("activeInputRoutingMode")); + integrity->setProperty("finiteOutput", finiteSamples); + integrity->setProperty("nonzeroOutput", nonzeroOutput); + integrity->setProperty("requiresNonzeroOutput", requiresNonzeroOutput); + if (!requiresNonzeroOutput) + integrity->setProperty("zeroOutputInterpretation", "Explicit gate contexts may legitimately close on a quiet supplied DI. Signal activity is diagnostic; loaded-resource execution, complete block processing, finite output and no fallback remain required."); + integrity->setProperty("processedExpectedBlocks", processedExpectedBlocks); + for (const char* id : { "realtimeDSPBlocked", "ampModelFaulted", "rackOutputEmergencyMuteActive" }) + integrity->setProperty(id, static_cast(diagnostics.getProperty(juce::Identifier(id), true))); + integrity->setProperty("noSafetyFallback", noFallback); + const bool casePassed = finiteSamples && (!requiresNonzeroOutput || nonzeroOutput) && resourcesReadback + && ampExecuted && cabActive && processedExpectedBlocks && noFallback && guardHits >= 0; + integrity->setProperty("passed", casePassed); + row->setProperty("integrity", juce::var(integrity)); + fullChainPassed = fullChainPassed && casePassed; + structuralPassed = structuralPassed && casePassed; + row->setProperty("audioQuality", "not_asserted"); entries.add(juce::var(row)); + rack->releaseResources(); + } + } + } + const int expectedCount = static_cast(sources.size()) * (extended ? 177 : 32); + fullChainPassed = fullChainPassed && entries.size() == expectedCount + && writtenNames.size() == expectedCount; + structuralPassed = structuralPassed && fullChainPassed; + add(checks, "nam_fuzz_full_chain_matrix", fullChainPassed && writesPassed ? "pass" : "fail", + extended + ? "Extended: 64 discrete Octave/Fuzz voice/Drive/Distortion mode topologies at two control sets, plus 49 conservative Clean/gate/calibration/octave-presence probes per material. Exact resource readback, NAM execution, active Cab, complete block count, finite output, unique file writes and no fallback are asserted. Nonzero output is required except in explicit gate contexts, which may legitimately close. Supplemental controls are bounded interaction coverage, not every continuous combination. Ordinary guard hits are diagnostic; tone and hardware realtime performance remain not_asserted." + : "Eight production Fuzz/Drive/Distortion combinations cover conservative/stress controls and every active Fuzz voice through the explicit NAM amp/cab. Exact resource readback, real NAM frames, active external cabinet, complete block processing, finite nonzero output, file writes and no model/oversize/snapshot/emergency-mute fallback are asserted. Ordinary guard hits are diagnostic; tone and physical-device realtime performance remain not_asserted.", entries.size()); + add(checks, "nam_fuzz_full_chain_output_guard", "diagnostic_only", + "Cumulative ordinary production output-guard hits across the full-chain cases; raw native levels are retained. This count is not a clipping-audibility or tone-quality gate.", ordinaryOutputGuardHits); + return juce::var(entries); + } +}; diff --git a/Source/NAMFuzzRegression.h b/Source/NAMFuzzRegression.h new file mode 100644 index 0000000..e94ea08 --- /dev/null +++ b/Source/NAMFuzzRegression.h @@ -0,0 +1,802 @@ +#pragma once + +#include "AudioEngine.h" +#include "BuiltInEffects2.h" +#include "NAMFuzz.h" +#include "NAMFuzzAliasRegression.h" +#include "NAMFuzzInteractionRegression.h" +#include "NAMFuzzSignalRegression.h" +#include +#include +#include +#include +#include +#include +#include + +// Deterministic contracts only. Voice measurements are diagnostics, not an +// assertion of pedal similarity, naturalness, or user-approved sound quality. +class NAMFuzzRegression final +{ +public: + static juce::Array run(AudioEngine& engine) + { + juce::Array checks; + for (const double rate : { 44100.0, 48000.0, 96000.0 }) + { + runCore(checks, rate); + runCalibration(checks, rate); + runOctaverLifecycle(checks, rate); + } + runReprepare(checks); + runBassGateContract(checks); + runRackRouting(checks); + runState(checks); + runBridge(checks, engine); + checks.add(OpenStudioNAMRack::runDistortionADAARegression()); + checks.add(OpenStudioSaturator::runLowCutPrecisionRegression()); + checks.add(NAMFuzzSignalRegression::runPartitionSmoke()); + for (const auto& check : NAMFuzzSignalRegression::run()) checks.add(check); + for (const auto& check : NAMFuzzInteractionRegression::run()) checks.add(check); + for (const auto& check : NAMFuzzAliasRegression::run()) checks.add(check); + add(checks, "nam_fuzz_subjective_audio_quality", "not_asserted", + "Tone, octave-chord articulation, pedal similarity, alias audibility and guitar/bass preference require audition of the exact output. Deterministic checks do not approve sound quality."); + return checks; + } + +private: + static void runOctaverLifecycle(juce::Array& checks, double rate) + { + const auto result = NAMPolyOctaver::runLifecycleSelfTest(rate); + auto* value = new juce::DynamicObject(); + value->setProperty("sampleRate", rate); + value->setProperty("retirementPartitionPassed", result.retirementPartitionPassed); + value->setProperty("unaffectedVoicePassed", result.unaffectedVoicePassed); + value->setProperty("retiredProfileRecallPassed", result.retiredProfileRecallPassed); + value->setProperty("fuzzPairPartitionPassed", result.fuzzPairPartitionPassed); + value->setProperty("retirementComparisons", result.retirementComparisons); + value->setProperty("profileComparisons", result.profileComparisons); + value->setProperty("fuzzPairComparisons", result.fuzzPairComparisons); + value->setProperty("maximumRetirementDifference", result.maximumRetirementDifference); + value->setProperty("maximumMetadataDifference", result.maximumMetadataDifference); + value->setProperty("maximumUnaffectedVoiceDifference", result.maximumUnaffectedVoiceDifference); + value->setProperty("maximumRetiredProfileDifference", result.maximumRetiredProfileDifference); + value->setProperty("maximumFuzzPairDifference", result.maximumFuzzPairDifference); + add(checks, "nam_octaver_retirement_" + juce::String(static_cast(rate)), + result.passed ? "pass" : "fail", + "Exact full/individual octave retirement and re-entry across all six resampler phases, seven off-dwell lengths, mono/stereo and Guitar/Bass; continuing voices and excitation metadata remain exact. Off-edited profiles match fresh state, and all three Fuzz voices preserve pair partition parity. Sound quality is not asserted.", + juce::var(value)); + } + + struct Event + { + int sample = 0; + NAMFuzz::Parameters parameters; + }; + + struct Field + { + const char* id; + float minimum, maximum, initial, saved; + }; + + inline static constexpr std::array fields {{ + { "fuzzEnabled", 0.0f, 1.0f, 0.0f, 1.0f }, + { "fuzzDrive", 0.0f, 1.0f, 0.5f, 0.73f }, + { "fuzzTone", 0.0f, 1.0f, 0.5f, 0.31f }, + { "fuzzLevelDb", -18.0f, 12.0f, 0.0f, -4.25f }, + { "fuzzVoice", 0.0f, 2.0f, 0.0f, 2.0f }, + { "fuzzCleanMix", 0.0f, 1.0f, 0.0f, 0.27f }, + }}; + + static void add(juce::Array& checks, const juce::String& id, + const juce::String& status, const juce::String& detail, + const juce::var& value = {}) + { + auto* result = new juce::DynamicObject(); + result->setProperty("id", id); + result->setProperty("status", status); + result->setProperty("detail", detail); + if (!value.isVoid()) result->setProperty("value", value); + checks.add(juce::var(result)); + } + + static juce::AudioBuffer fixture(double rate, int channels = 2, int count = 12019) + { + juce::AudioBuffer buffer(channels, count); + for (int channel = 0; channel < channels; ++channel) + for (int sample = 0; sample < count; ++sample) + { + const double time = static_cast(sample) / rate; + const double frequency = channel == 0 ? 220.0 : 329.6275569; + const float value = static_cast(0.2 * std::sin(juce::MathConstants::twoPi * frequency * time) + + 0.075 * std::sin(juce::MathConstants::twoPi * frequency * 0.375 * time)); + buffer.setSample(channel, sample, value + (sample == 7 ? 0.1f : 0.0f)); + } + return buffer; + } + + static juce::AudioBuffer render(NAMFuzz& fuzz, const juce::AudioBuffer& input, + NAMFuzz::Parameters parameters, + const std::vector& partitions, + const std::vector& events = {}) + { + juce::AudioBuffer output; + output.makeCopyOf(input); + size_t eventIndex = 0, partitionIndex = 0; + int position = 0; + while (position < output.getNumSamples()) + { + while (eventIndex < events.size() && events[eventIndex].sample == position) + parameters = events[eventIndex++].parameters; + fuzz.setParameters(parameters); // Production publishes targets every callback. + int count = juce::jmin(partitions[partitionIndex++ % partitions.size()], output.getNumSamples() - position); + if (eventIndex < events.size()) count = juce::jmin(count, events[eventIndex].sample - position); + std::array pointers { + output.getWritePointer(0, position), + output.getNumChannels() > 1 ? output.getWritePointer(1, position) : nullptr, + }; + juce::AudioBuffer block(pointers.data(), output.getNumChannels(), count); + fuzz.processBlock(block); + position += count; + } + return output; + } + + static double difference(const juce::AudioBuffer& left, const juce::AudioBuffer& right) + { + double maximum = 0.0; + if (left.getNumChannels() != right.getNumChannels() || left.getNumSamples() != right.getNumSamples()) + return std::numeric_limits::infinity(); + for (int channel = 0; channel < left.getNumChannels(); ++channel) + for (int sample = 0; sample < left.getNumSamples(); ++sample) + { + const double delta = std::abs(static_cast(left.getSample(channel, sample)) - right.getSample(channel, sample)); + if (!std::isfinite(delta)) return std::numeric_limits::infinity(); + maximum = juce::jmax(maximum, delta); + } + return maximum; + } + + static double energy(const juce::AudioBuffer& buffer, int start = 0) + { + double sum = 0.0; + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + for (int sample = start; sample < buffer.getNumSamples(); ++sample) + { + const double value = buffer.getSample(channel, sample); + if (!std::isfinite(value)) return std::numeric_limits::infinity(); + sum += value * value; + } + return sum / static_cast(juce::jmax(1, buffer.getNumChannels() * (buffer.getNumSamples() - start))); + } + + static bool finite(const juce::AudioBuffer& buffer) + { + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + if (!std::isfinite(buffer.getSample(channel, sample))) return false; + return true; + } + + static void runCore(juce::Array& checks, double rate) + { + const auto source = fixture(rate); + const std::vector regular { 512 }, irregular { 1, 7, 31, 64, 193, 1024, 3, 257 }; + auto* result = new juce::DynamicObject(); + result->setProperty("sampleRate", rate); + bool bypassPassed = true, silencePassed = true, stereoPassed = true, resetPassed = true; + bool partitionPassed = true, finiteRecoveryPassed = true, cleanBlendPassed = true, cleanLinearBlendPassed = true; + bool malformedControlParityPassed = true, monoAndEmptyPassed = true; + double maximumPartitionDifference = 0.0; + juce::Array voiceDiagnostics; + std::array, 3> voiceOutputs; + + NAMFuzz::Parameters off; + NAMFuzz bypass; + bypass.prepare(rate, off); + bypassPassed = difference(render(bypass, source, off, irregular), source) == 0.0; + + for (int voice = 0; voice < 3; ++voice) + { + NAMFuzz::Parameters parameters; + parameters.enabled = true; + parameters.drive = 0.72f; + parameters.voice = voice; + for (const bool bass : { false, true }) + { + parameters.bass = bass; + NAMFuzz regularFuzz, irregularFuzz; + regularFuzz.prepare(rate, parameters); + irregularFuzz.prepare(rate, parameters); + const auto regularOutput = render(regularFuzz, source, parameters, regular); + const auto irregularOutput = render(irregularFuzz, source, parameters, irregular); + const double delta = difference(regularOutput, irregularOutput); + maximumPartitionDifference = juce::jmax(maximumPartitionDifference, delta); + partitionPassed = partitionPassed && delta == 0.0; + if (!bass) voiceOutputs[static_cast(voice)].makeCopyOf(regularOutput); + + regularFuzz.reset(); + resetPassed = resetPassed && difference(render(regularFuzz, source, parameters, irregular), regularOutput) == 0.0; + + juce::AudioBuffer silence(2, 8192); + silence.clear(); + regularFuzz.prepare(rate, parameters); + const auto silenceOutput = render(regularFuzz, silence, parameters, irregular); + silencePassed = silencePassed && finite(silenceOutput) && energy(silenceOutput) == 0.0; + + juce::AudioBuffer leftOnly; + leftOnly.makeCopyOf(source); + leftOnly.clear(1, 0, leftOnly.getNumSamples()); + regularFuzz.prepare(rate, parameters); + const auto isolated = render(regularFuzz, leftOnly, parameters, irregular); + for (int sample = 0; sample < source.getNumSamples(); ++sample) + stereoPassed = stereoPassed && isolated.getSample(0, sample) == regularOutput.getSample(0, sample) + && isolated.getSample(1, sample) == 0.0f; + + juce::AudioBuffer equalStereo; + equalStereo.makeCopyOf(source); + equalStereo.copyFrom(1, 0, source, 0, 0, source.getNumSamples()); + regularFuzz.prepare(rate, parameters); + const auto equalOutput = render(regularFuzz, equalStereo, parameters, regular); + for (int sample = 0; sample < source.getNumSamples(); ++sample) + stereoPassed = stereoPassed && equalOutput.getSample(0, sample) == equalOutput.getSample(1, sample); + + juce::AudioBuffer mono(1, source.getNumSamples()); + mono.copyFrom(0, 0, source, 0, 0, source.getNumSamples()); + regularFuzz.prepare(rate, parameters); + juce::AudioBuffer noChannels(0, 128), noSamples(2, 0); + regularFuzz.processBlock(noChannels); + regularFuzz.processBlock(noSamples); + const auto monoOutput = render(regularFuzz, mono, parameters, irregular); + for (int sample = 0; sample < source.getNumSamples(); ++sample) + monoAndEmptyPassed = monoAndEmptyPassed && monoOutput.getSample(0, sample) == regularOutput.getSample(0, sample); + + parameters.cleanMix = 1.0f; + regularFuzz.prepare(rate, parameters); + cleanBlendPassed = cleanBlendPassed && difference(render(regularFuzz, source, parameters, irregular), source) <= 3.0e-8; + parameters.cleanMix = 0.5f; + regularFuzz.prepare(rate, parameters); + const auto halfBlend = render(regularFuzz, source, parameters, irregular); + juce::AudioBuffer expectedHalf(2, source.getNumSamples()); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < source.getNumSamples(); ++sample) + expectedHalf.setSample(channel, sample, 0.5f * regularOutput.getSample(channel, sample) + 0.5f * source.getSample(channel, sample)); + cleanLinearBlendPassed = cleanLinearBlendPassed && difference(halfBlend, expectedHalf) <= 1.0e-6; + parameters.cleanMix = 0.0f; + + auto malformed = parameters; + malformed.drive = std::numeric_limits::quiet_NaN(); + malformed.tone = std::numeric_limits::infinity(); + malformed.levelDb = -std::numeric_limits::infinity(); + malformed.cleanMix = std::numeric_limits::quiet_NaN(); + malformed.voice = std::numeric_limits::max(); + auto sanitized = parameters; + sanitized.drive = 0.5f; + sanitized.tone = 0.5f; + sanitized.levelDb = 0.0f; + sanitized.cleanMix = 0.0f; + sanitized.voice = 2; + regularFuzz.prepare(rate, malformed); + irregularFuzz.prepare(rate, sanitized); + malformedControlParityPassed = malformedControlParityPassed + && difference(render(regularFuzz, source, malformed, regular), render(irregularFuzz, source, sanitized, irregular)) == 0.0; + + auto extremeControls = parameters; + extremeControls.drive = std::numeric_limits::max(); + extremeControls.tone = -std::numeric_limits::max(); + extremeControls.levelDb = std::numeric_limits::max(); + extremeControls.cleanMix = -std::numeric_limits::max(); + extremeControls.voice = std::numeric_limits::min(); + sanitized.drive = 1.0f; + sanitized.tone = 0.0f; + sanitized.levelDb = 12.0f; + sanitized.voice = 0; + regularFuzz.prepare(rate, extremeControls); + irregularFuzz.prepare(rate, sanitized); + malformedControlParityPassed = malformedControlParityPassed + && difference(render(regularFuzz, source, extremeControls, regular), render(irregularFuzz, source, sanitized, irregular)) == 0.0; + + juce::AudioBuffer poisoned(2, 1024); + for (int sample = 0; sample < poisoned.getNumSamples(); ++sample) + { + const float value = sample % 4 == 0 ? std::numeric_limits::quiet_NaN() + : sample % 4 == 1 ? std::numeric_limits::infinity() + : sample % 4 == 2 ? std::numeric_limits::max() : -std::numeric_limits::max(); + poisoned.setSample(0, sample, value); + poisoned.setSample(1, sample, -value); + } + regularFuzz.prepare(rate, malformed); + const auto poisonOutput = render(regularFuzz, poisoned, malformed, irregular); + const auto recoveryInput = fixture(rate, 2, static_cast(rate * 0.3)); + const auto recoveryOutput = render(regularFuzz, recoveryInput, parameters, irregular); + finiteRecoveryPassed = finiteRecoveryPassed && finite(poisonOutput) && finite(recoveryOutput) + && energy(recoveryOutput, recoveryOutput.getNumSamples() / 2) > 1.0e-8; + } + auto* diagnostic = new juce::DynamicObject(); + diagnostic->setProperty("voice", voice); + diagnostic->setProperty("rms", std::sqrt(energy(voiceOutputs[static_cast(voice)]))); + diagnostic->setProperty("maximumDifferenceFromInput", difference(voiceOutputs[static_cast(voice)], source)); + voiceDiagnostics.add(juce::var(diagnostic)); + } + + // Control changes have exact sample positions independent of callback + // sizes, including a bypass interval followed by reactivation. + NAMFuzz::Parameters initial; + initial.enabled = true; + auto changed = initial; + changed.drive = 0.91f; + changed.tone = 0.22f; + changed.levelDb = -7.0f; + changed.cleanMix = 0.2f; + changed.voice = 2; + changed.bass = true; + auto disabled = changed; + disabled.enabled = false; + auto resumed = changed; + resumed.voice = 1; + const std::vector events { { 1037, changed }, { 3061, disabled }, { 7103, resumed } }; + NAMFuzz regularControls, irregularControls; + regularControls.prepare(rate, initial); + irregularControls.prepare(rate, initial); + const auto regularChanged = render(regularControls, source, initial, regular, events); + const auto irregularChanged = render(irregularControls, source, initial, irregular, events); + const double controlDifference = difference(regularChanged, irregularChanged); + const int bypassResumeSample = 1207 + static_cast(rate * 0.025) + 19; + auto brieflyDisabled = initial; + brieflyDisabled.enabled = false; + const std::vector bypassResumeEvents { { 1207, brieflyDisabled }, { bypassResumeSample, initial } }; + regularControls.prepare(rate, initial); + irregularControls.prepare(rate, initial); + const auto largeBlockResume = render(regularControls, source, initial, { 2048 }, bypassResumeEvents); + const auto oneSampleResume = render(irregularControls, source, initial, { 1 }, bypassResumeEvents); + const double bypassResumeDifference = difference(largeBlockResume, oneSampleResume); + const bool controlPartitionPassed = controlDifference == 0.0 && bypassResumeDifference == 0.0; + + // Disabled controls must have settled to exact bypass before this tail. + NAMFuzz disabledFuzz; + disabledFuzz.prepare(rate, initial); + (void) render(disabledFuzz, source, disabled, irregular); + bypassPassed = bypassPassed && difference(render(disabledFuzz, source, disabled, regular), source) == 0.0; + + bool malformedRatePassed = true; + for (const double malformedRate : { 0.0, -1.0, std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), std::numeric_limits::max() }) + { + NAMFuzz fuzz; + fuzz.prepare(malformedRate, initial); + const auto output = render(fuzz, source, initial, irregular); + malformedRatePassed = malformedRatePassed && finite(output) && energy(output) > 1.0e-8; + } + + result->setProperty("exactBypassPassed", bypassPassed); + result->setProperty("exactSilencePassed", silencePassed); + result->setProperty("stereoIsolationAndParityPassed", stereoPassed); + result->setProperty("resetDeterminismPassed", resetPassed); + result->setProperty("partitionInvariantPassed", partitionPassed); + result->setProperty("maximumPartitionDifference", maximumPartitionDifference); + result->setProperty("liveControlsPartitionPassed", controlPartitionPassed); + result->setProperty("maximumControlPartitionDifference", controlDifference); + result->setProperty("maximumBypassReactivationPartitionDifference", bypassResumeDifference); + result->setProperty("finiteInputAndControlRecoveryPassed", finiteRecoveryPassed); + result->setProperty("malformedSampleRatePassed", malformedRatePassed); + result->setProperty("fullCleanBlendParityPassed", cleanBlendPassed); + result->setProperty("linearParallelCleanBlendPassed", cleanLinearBlendPassed); + result->setProperty("sanitizedMalformedAndExtremeControlParityPassed", malformedControlParityPassed); + result->setProperty("monoAndEmptyBufferPassed", monoAndEmptyPassed); + const bool passed = bypassPassed && silencePassed && stereoPassed && resetPassed && partitionPassed + && controlPartitionPassed && finiteRecoveryPassed && malformedRatePassed && cleanBlendPassed && cleanLinearBlendPassed + && malformedControlParityPassed && monoAndEmptyPassed; + add(checks, "nam_fuzz_multirate_contract_" + juce::String(static_cast(rate)), passed ? "pass" : "fail", + "Exact fresh/settled bypass and silence; independent stereo; deterministic reset; arbitrary callback partitions including live control/voice/profile/bypass changes; finite malformed/extreme input recovery and full-clean parity. No subjective tone claim.", juce::var(result)); + auto* diagnostic = new juce::DynamicObject(); + diagnostic->setProperty("sampleRate", rate); + diagnostic->setProperty("voices", juce::var(voiceDiagnostics)); + diagnostic->setProperty("vintageSustainDifference", difference(voiceOutputs[0], voiceOutputs[1])); + diagnostic->setProperty("vintageGatedDifference", difference(voiceOutputs[0], voiceOutputs[2])); + diagnostic->setProperty("sustainGatedDifference", difference(voiceOutputs[1], voiceOutputs[2])); + add(checks, "nam_fuzz_voice_measurements_" + juce::String(static_cast(rate)), "diagnostic_only", + "Synthetic-fixture RMS and voice differences describe distinct mathematical outputs; they do not establish a preferred fuzz tone, pedal match, alias audibility, or octave sound quality.", juce::var(diagnostic)); + } + + static void runBassGateContract(juce::Array& checks) + { + constexpr double sampleRate=48000.0; + constexpr float threshold=0.003f; // Drive0.5:0.0015+0.003*(1-Drive). + constexpr std::array lengths{12000,24000,28800,19200,12000}; + constexpr std::array peaks{2.0f*threshold,0.8f*threshold,0.0f,0.8f*threshold,2.0f*threshold}; + constexpr std::array labels{"loud-open","between-threshold-retention","silence-close","quiet-stays-closed","loud-reopen"}; + const int count=std::accumulate(lengths.begin(),lengths.end(),0); + juce::AudioBuffer input(2,count);int position=0; + for(size_t segment=0;segment(std::sin(juce::MathConstants::twoPi*30.8677063*position/sampleRate)); + input.setSample(0,position,value);input.setSample(1,position,value); + } + struct GateRun {juce::AudioBuffer audio;std::array snapshots;}; + const auto process=[&input,&lengths,sampleRate] (int blockSize,float excitationGain) + { + GateRun run;run.audio.makeCopyOf(input);NAMFuzz processor;NAMFuzz::Parameters controls; + controls.enabled=true;controls.bass=true;controls.voice=2;controls.drive=0.5f;processor.prepare(sampleRate,controls); + std::array excitation{};excitation.fill(excitationGain);int start=0; + for(size_t segment=0;segment pointers{run.audio.getWritePointer(0,start),run.audio.getWritePointer(1,start)}; + juce::AudioBuffer block(pointers.data(),2,blockCount);processor.processBlock(block,excitation.data());start+=blockCount; + } + run.snapshots[segment]=processor.getGateDiagnostics(); + } + return run; + }; + const auto regular=process(512,1.0f),irregular=process(7,1.0f),padded=process(31,0.25f); + bool passed=finite(regular.audio)&&finite(irregular.audio)&&finite(padded.audio)&&difference(regular.audio,irregular.audio)==0.0; + juce::Array segments; + for(size_t segment=0;segment=2?1:0; + const auto& state=regular.snapshots[segment];auto* row=new juce::DynamicObject(); + row->setProperty("segment",labels[segment]);row->setProperty("inputPeak",peaks[segment]);row->setProperty("open",state.open[0]); + row->setProperty("opens",static_cast(state.opens[0]));row->setProperty("closes",static_cast(state.closes[0])); + row->setProperty("detectorEnvelope",state.envelope[0]);row->setProperty("gateGain",state.gain[0]); + for(size_t channel=0;channel<2;++channel) + { + const auto& other=irregular.snapshots[segment];const auto& compensated=padded.snapshots[segment]; + passed=passed&&state.open[channel]==expectedOpen&&state.opens[channel]==expectedOpens&&state.closes[channel]==expectedCloses + &&state.open[channel]==state.open[0]&&state.opens[channel]==state.opens[0]&&state.closes[channel]==state.closes[0] + &&state.envelope[channel]==state.envelope[0]&&state.gain[channel]==state.gain[0] + &&state.open[channel]==other.open[channel]&&state.opens[channel]==other.opens[channel]&&state.closes[channel]==other.closes[channel] + &&state.envelope[channel]==other.envelope[channel]&&state.gain[channel]==other.gain[channel] + &&state.open[channel]==compensated.open[channel]&&state.opens[channel]==compensated.opens[channel]&&state.closes[channel]==compensated.closes[channel] + &&state.envelope[channel]==compensated.envelope[channel]&&state.gain[channel]==compensated.gain[channel]; + } + segments.add(juce::var(row)); + } + add(checks,"nam_fuzz_bass_gate_hysteresis_contract",passed?"pass":"fail", + "At Drive0.5 Bass B0 opens above T, retains between T/2 and T, closes during600ms silence, remains closed for sub-T reentry and reopens above T. Gate transition/envelope/gain state is partition invariant, stereo equal and independent of nonlinear excitation padding. This is a deterministic state contract, not a tone-quality approval.",juce::var(segments)); + } + + static void runReprepare(juce::Array& checks) + { + bool passed = true; + double maximumDifference = 0.0; + juce::Array cases; + for (const bool bass : { false, true }) for (int voice = 0; voice < 3; ++voice) + { + NAMFuzz::Parameters parameters; + parameters.enabled = true; parameters.bass = bass; parameters.voice = voice; + parameters.drive = 0.72f; parameters.tone = 0.31f; parameters.cleanMix = 0.27f; + parameters.calibrationReferenceDbu = 18.0f; + NAMFuzz reused; + reused.prepare(44100.0, parameters); + (void) render(reused, fixture(44100.0), parameters, { 512 }); + for (const double targetRate : { 44100.0, 48000.0, 96000.0, 44100.0 }) + { + // Deliberately unchanged parameters must not bypass coefficient + // construction after prepare changes the sample rate or resets + // ramps. The second 44.1 kHz visit also checks returning from96k. + const auto source = fixture(targetRate); + reused.prepare(targetRate, parameters); + NAMFuzz fresh; fresh.prepare(targetRate, parameters); + const auto reusedOutput = render(reused, source, parameters, { 512 }); + const auto freshOutput = render(fresh, source, parameters, { 7, 31, 1 }); + const double delta = difference(reusedOutput, freshOutput); + maximumDifference = juce::jmax(maximumDifference, delta); + passed = passed && finite(reusedOutput) && finite(freshOutput) && delta == 0.0; + auto* row = new juce::DynamicObject(); + row->setProperty("bass", bass); row->setProperty("voice", voice); + row->setProperty("targetSampleRate", targetRate); row->setProperty("maximumDifference", delta); + cases.add(juce::var(row)); + } + } + auto* value = new juce::DynamicObject(); + value->setProperty("maximumDifference", maximumDifference); + value->setProperty("calibrationReferenceDbu", 18.0f); + value->setProperty("cases", juce::var(cases)); + add(checks, "nam_fuzz_same_controls_reprepare", passed ? "pass" : "fail", + "A warmed44.1k processor re-prepared at44.1/48/96/44.1k with unchanged controls must exactly match a fresh processor at each target rate, across Guitar/Bass and all three voices. This verifies coefficient-cache invalidation, filter/reset state and callback partition semantics, not sound quality.", juce::var(value)); + } + + static void runCalibration(juce::Array& checks, double rate) + { + const auto physicalSource = fixture(rate); + double maximumPhysicalDifference = 0.0, maximumPartitionDifference = 0.0; + bool passed = true; + for (const bool bass : { false, true }) for (int voice = 0; voice < 3; ++voice) + { + for (const float cleanMix : { 0.0f, 0.5f, 1.0f }) + { + NAMFuzz::Parameters parameters; + parameters.enabled = true; parameters.voice = voice; + parameters.bass = bass; parameters.cleanMix = cleanMix; + NAMFuzz referenceProcessor; + referenceProcessor.prepare(rate, parameters); + const auto reference = render(referenceProcessor, physicalSource, parameters, { 512 }); + for (const float referenceDbu : { 6.0f, 12.0f, 18.0f, 24.0f }) + { + const float operatingGain = juce::Decibels::decibelsToGain(referenceDbu - 12.0f); + juce::AudioBuffer digitalSource; digitalSource.makeCopyOf(physicalSource); + digitalSource.applyGain(1.0f / operatingGain); + parameters.calibrationReferenceDbu = referenceDbu; + NAMFuzz regularProcessor, irregularProcessor; + regularProcessor.prepare(rate, parameters); irregularProcessor.prepare(rate, parameters); + auto regular = render(regularProcessor, digitalSource, parameters, { 512 }); + const auto irregular = render(irregularProcessor, digitalSource, parameters, { 7, 31, 1 }); + maximumPartitionDifference = juce::jmax(maximumPartitionDifference, difference(regular, irregular)); + passed = passed && finite(regular) && finite(irregular); + if (cleanMix == 1.0f) passed = passed && difference(regular, digitalSource) == 0.0; + regular.applyGain(operatingGain); + maximumPhysicalDifference = juce::jmax(maximumPhysicalDifference, difference(reference, regular)); + } + } + } + // A live reference change must keep exactly the same sample-time ramp + // across host callback boundaries, including a malformed value fallback. + NAMFuzz::Parameters initial; initial.enabled = true; + auto changed = initial; changed.calibrationReferenceDbu = 24.0f; + auto malformed = initial; malformed.calibrationReferenceDbu = std::numeric_limits::quiet_NaN(); + const std::vector events {{ 79, changed }, { 6031, malformed }}; + NAMFuzz regularProcessor, irregularProcessor; + regularProcessor.prepare(rate, initial); irregularProcessor.prepare(rate, initial); + const auto regular = render(regularProcessor, physicalSource, initial, { 512 }, events); + const auto irregular = render(irregularProcessor, physicalSource, initial, { 7, 31, 1 }, events); + maximumPartitionDifference = juce::jmax(maximumPartitionDifference, difference(regular, irregular)); + passed = passed && finite(regular) && finite(irregular) + && maximumPhysicalDifference <= 1.0e-5 && maximumPartitionDifference == 0.0; + auto* value = new juce::DynamicObject(); + value->setProperty("sampleRate", rate); + value->setProperty("maximumPhysicalOutputDifference", maximumPhysicalDifference); + value->setProperty("physicalOutputTolerance", 1.0e-5); + value->setProperty("maximumPartitionDifference", maximumPartitionDifference); + value->setProperty("referencesDbu", "6,12,18,24"); + value->setProperty("coverage", "Guitar/Bass x three voices x Clean0/0.5/1; source scaled inversely with interface reference, output converted back to a common physical domain. Live reference changes and NaN fallback are finite and partition invariant; full-clean remains exact incoming digital audio."); + add(checks, "nam_fuzz_calibration_" + juce::String(static_cast(rate)), passed ? "pass" : "fail", + "Equivalent physical input must retain equivalent physical fuzz output across interface references without modifying raw clean audio. This asserts calibration and ramp semantics, not audible preference.", juce::var(value)); + } + + static juce::MemoryBlock memory(const juce::ValueTree& tree) + { + juce::MemoryBlock block; + juce::MemoryOutputStream stream(block, false); + tree.writeToStream(stream); + return block; + } + + static juce::AudioBuffer renderRackFixture(const juce::AudioBuffer& source, + bool octaveEnabled, bool fuzzEnabled) + { + // Test setup allocations stay off the audio callback. No NAM capture, + // cabinet IR, or external fixture is needed to exercise the live rack. + const auto rack = std::make_unique(); + rack->setRoutedInputChannelCount(2); + rack->inputTrimDb.store(0.0f); + rack->outputTrimDb.store(0.0f); + rack->gateThresholdDb.store(-100.0f); + rack->compressorEnabled.store(0.0f); + rack->octaverEnabled.store(octaveEnabled ? 1.0f : 0.0f); + rack->octaverDownMix.store(0.32f); + rack->octaverUpMix.store(0.18f); + rack->octaverDirectMix.store(1.0f); + rack->fuzzEnabled.store(fuzzEnabled ? 1.0f : 0.0f); + rack->fuzzDrive.store(0.73f); + rack->fuzzTone.store(0.31f); + rack->fuzzLevelDb.store(-4.25f); + rack->fuzzVoice.store(0.0f); + rack->fuzzCleanMix.store(0.27f); + rack->precisionDriveEnabled.store(0.0f); + rack->chaosEnabled.store(0.0f); + rack->pedalMix.store(0.0f); + rack->ampEnabled.store(0.0f); + rack->ampMix.store(0.0f); + rack->setCabRequestedEnabled(false); + rack->preEqEnabled.store(0.0f); + 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); + rack->prepareToPlay(48000.0, 512); + juce::AudioBuffer output; + output.makeCopyOf(source); + juce::MidiBuffer midi; + for (int position = 0; position < output.getNumSamples(); position += 512) + { + const int count = juce::jmin(512, output.getNumSamples() - position); + std::array pointers { output.getWritePointer(0, position), output.getWritePointer(1, position) }; + juce::AudioBuffer block(pointers.data(), 2, count); + rack->processBlock(block, midi); + } + rack->releaseResources(); + return output; + } + + static void runRackRouting(juce::Array& checks) + { + const auto source = fixture(48000.0, 2, 16384); + const auto bypass = renderRackFixture(source, false, false); + const auto fuzzOnly = renderRackFixture(source, false, true); + const auto octaveOnly = renderRackFixture(source, true, false); + const auto combined = renderRackFixture(source, true, true); + const auto repeatedCombined = renderRackFixture(source, true, true); + const double fuzzOnlyDifference = difference(fuzzOnly, bypass); + const double combinedDifference = difference(combined, octaveOnly); + const double repeatedDifference = difference(combined, repeatedCombined); + const bool passed = finite(bypass) && finite(fuzzOnly) && finite(octaveOnly) && finite(combined) + && energy(fuzzOnly) > 1.0e-8 && energy(combined) > 1.0e-8 + && fuzzOnlyDifference > 1.0e-4 && combinedDifference > 1.0e-4 && repeatedDifference == 0.0; + auto* result = new juce::DynamicObject(); + result->setProperty("sampleRate", 48000.0); + result->setProperty("noExternalModelOrCabIR", true); + result->setProperty("fuzzWorksWithOctaveDisabled", fuzzOnlyDifference > 1.0e-4); + result->setProperty("fuzzWorksWithOctaveEnabled", combinedDifference > 1.0e-4); + result->setProperty("fuzzOnlyDifferenceFromBypass", fuzzOnlyDifference); + result->setProperty("combinedDifferenceFromOctaveOnly", combinedDifference); + result->setProperty("repeatedCombinedMaximumDifference", repeatedDifference); + add(checks, "nam_fuzz_integrated_rack_routes", passed ? "pass" : "fail", + "The live stereo Rack must produce finite, deterministic Fuzz-only output with Octave off and Octave+Fuzz output with Octave on, with NAM/Amp/Cab and other effects bypassed. Output differences prove route execution, not tone quality.", juce::var(result)); + } + + static juce::ValueTree tree(const juce::MemoryBlock& block) + { + return juce::ValueTree::readFromData(block.getData(), block.getSize()); + } + + static juce::var values(bool saved) + { + auto* object = new juce::DynamicObject(); + for (const auto& field : fields) object->setProperty(field.id, static_cast(saved ? field.saved : field.initial)); + return juce::var(object); + } + + static bool matchValues(const juce::var& actual, bool saved) + { + auto* object = actual.getDynamicObject(); + if (object == nullptr) return false; + for (const auto& field : fields) + if (!object->hasProperty(field.id) || std::abs(static_cast(object->getProperty(field.id)) - (saved ? field.saved : field.initial)) > 1.0e-6) + return false; + return true; + } + + static bool matchTree(const juce::ValueTree& actual, bool saved) + { + for (const auto& field : fields) + if (!actual.hasProperty(field.id) || std::abs(static_cast(actual.getProperty(field.id)) - (saved ? field.saved : field.initial)) > 1.0e-6) + return false; + return actual.isValid(); + } + + static void runState(juce::Array& checks) + { + const auto source = std::make_unique(); + juce::MemoryBlock original; + source->getStateInformation(original); + auto currentTree = tree(original); + bool defaultsPassed = matchTree(currentTree, false); + for (const auto& field : fields) currentTree.setProperty(field.id, static_cast(field.saved), nullptr); + const auto configured = memory(currentTree); + const bool nativeAccepted = source->restoreProjectStateInformation(configured.getData(), static_cast(configured.getSize())); + juce::MemoryBlock roundtrip; + source->getStateInformation(roundtrip); + bool roundtripPassed = nativeAccepted && matchTree(tree(roundtrip), true); + + juce::MemoryBlock portable; + source->getTonePresetStateInformation(portable); + const auto restored = std::make_unique(); + const bool portableAccepted = restored->restoreTonePresetStateInformation(portable.getData(), static_cast(portable.getSize())); + juce::MemoryBlock portableRoundtrip; + restored->getStateInformation(portableRoundtrip); + const bool portablePassed = portableAccepted && matchTree(tree(portable), true) && matchTree(tree(portableRoundtrip), true); + + // Restore an old project into an already-enabled instance: absent fuzz + // must reset to safe defaults rather than inherit the previous tone. + auto legacy = currentTree.createCopy(); + for (const auto& field : fields) legacy.removeProperty(field.id, nullptr); + const auto legacyMemory = memory(legacy); + const bool legacyAccepted = source->restoreProjectStateInformation(legacyMemory.getData(), static_cast(legacyMemory.getSize())); + source->getStateInformation(roundtrip); + const bool legacyPassed = legacyAccepted && matchTree(tree(roundtrip), false); + + auto uiState = juce::JSON::parse(R"json({"namPresetBaseline":{"values":{}},"namRackCompare":{"snapshots":{"A":{"values":{}},"B":{"values":{}}}}})json"); + auto* uiObject = uiState.getDynamicObject(); + auto baseline = uiObject->getProperty("namPresetBaseline"); + baseline.getDynamicObject()->setProperty("values", values(true)); + const auto compare = uiObject->getProperty("namRackCompare"); + const auto snapshots = compare.getProperty("snapshots", {}); + const auto snapshotA = snapshots.getProperty("A", {}); + snapshotA.getDynamicObject()->setProperty("values", values(true)); + (void) OpenStudioNAMRack::migrateUiStateToCurrent(uiState, 20); + const auto migratedSnapshots = uiState.getProperty("namRackCompare", {}).getProperty("snapshots", {}); + const bool comparePassed = matchValues(uiState.getProperty("namPresetBaseline", {}).getProperty("values", {}), true) + && matchValues(migratedSnapshots.getProperty("A", {}).getProperty("values", {}), true) + && matchValues(migratedSnapshots.getProperty("B", {}).getProperty("values", {}), false); + source->setUiStateJSON(juce::JSON::toString(uiState, false)); + source->getTonePresetStateInformation(portable); + const bool uiPortableAccepted = restored->restoreTonePresetStateInformation(portable.getData(), static_cast(portable.getSize())); + const auto restoredUI = juce::JSON::parse(restored->getUiStateJSON()); + const auto restoredSnapshots = restoredUI.getProperty("namRackCompare", {}).getProperty("snapshots", {}); + const bool compareRoundtripPassed = uiPortableAccepted + && matchValues(restoredUI.getProperty("namPresetBaseline", {}).getProperty("values", {}), true) + && matchValues(restoredSnapshots.getProperty("A", {}).getProperty("values", {}), true) + && matchValues(restoredSnapshots.getProperty("B", {}).getProperty("values", {}), false); + const bool versionsPassed = OpenStudioNAMRack::currentNAMEffectsDspVersion == 20 + && OpenStudioNAMRack::currentReverbEngineVersion == 5 + && static_cast(tree(roundtrip).getProperty("namEffectsDspVersion")) == 20 + && static_cast(tree(portable).getProperty("tonePresetStateVersion")) == 1; + auto* result = new juce::DynamicObject(); + result->setProperty("defaultFuzzOffPassed", defaultsPassed); + result->setProperty("nativeRoundtripPassed", roundtripPassed); + result->setProperty("portablePresetRoundtripPassed", portablePassed); + result->setProperty("legacyMissingFuzzResetsPassed", legacyPassed); + result->setProperty("compareMigrationPassed", comparePassed); + result->setProperty("comparePortableRoundtripPassed", compareRoundtripPassed); + result->setProperty("existingVersionMarkersUnchanged", versionsPassed); + const bool passed = defaultsPassed && roundtripPassed && portablePassed && legacyPassed && comparePassed && compareRoundtripPassed && versionsPassed; + add(checks, "nam_fuzz_native_legacy_preset_compare_state", passed ? "pass" : "fail", + "All six fuzz controls persist through native projects, portable tone presets, preset baselines and Compare A/B; legacy state resets fuzz off with factory defaults. Existing schema/DSP markers stay unchanged.", juce::var(result)); + } + + static double bridgeValue(const juce::var& schema, const char* id) + { + const auto parameters = schema.getProperty("values", {}); + if (const auto* object = parameters.getDynamicObject()) + if (object->hasProperty(id)) return static_cast(object->getProperty(id)); + return std::numeric_limits::quiet_NaN(); + } + + static bool valueMatches(double value, double expected) + { + return std::isfinite(value) && std::abs(value - expected) <= 1.0e-6; + } + + static void runBridge(juce::Array& checks, AudioEngine& engine) + { + const juce::String trackId = "nam-fuzz-regression-" + juce::Uuid().toString(); + const bool trackAdded = engine.addTrack(trackId, "audio").isNotEmpty(); + const bool rackAdded = trackAdded && engine.addTrackBuiltInFX(trackId, "OpenStudio NAM Rack", false); + bool defaultPassed = rackAdded, clampsPassed = rackAdded, roundtripPassed = rackAdded; + if (rackAdded) + { + auto schema = engine.getBuiltInPluginState(trackId, "track", 0); + defaultPassed = static_cast(schema.getProperty("schemaVersion", 0)) == 1; + for (const auto& field : fields) + { + defaultPassed = defaultPassed && valueMatches(bridgeValue(schema, field.id), field.initial); + const bool lowAccepted = engine.setBuiltInPluginParam(trackId, "track", 0, field.id, field.minimum - 100.0f); + const bool lowMatched = valueMatches(bridgeValue(engine.getBuiltInPluginState(trackId, "track", 0), field.id), field.minimum); + const bool highAccepted = engine.setBuiltInPluginParam(trackId, "track", 0, field.id, field.maximum + 100.0f); + const bool highMatched = valueMatches(bridgeValue(engine.getBuiltInPluginState(trackId, "track", 0), field.id), field.maximum); + clampsPassed = clampsPassed && lowAccepted && highAccepted && lowMatched && highMatched; + roundtripPassed = engine.setBuiltInPluginParam(trackId, "track", 0, field.id, field.saved) && roundtripPassed; + } + schema = engine.getBuiltInPluginState(trackId, "track", 0); + auto* state = new juce::DynamicObject(); + state->setProperty("schemaVersion", 1); + state->setProperty("values", values(true)); + roundtripPassed = engine.setBuiltInPluginState(trackId, "track", 0, juce::JSON::toString(juce::var(state), false)) && roundtripPassed; + const auto restoredSchema = engine.getBuiltInPluginState(trackId, "track", 0); + for (const auto& field : fields) + roundtripPassed = roundtripPassed && valueMatches(bridgeValue(schema, field.id), field.saved) && valueMatches(bridgeValue(restoredSchema, field.id), field.saved); + const bool fractionalAccepted = engine.setBuiltInPluginParam(trackId, "track", 0, "fuzzVoice", 1.75f); + clampsPassed = clampsPassed && fractionalAccepted + && valueMatches(bridgeValue(engine.getBuiltInPluginState(trackId, "track", 0), "fuzzVoice"), 2.0); + const bool malformedRejected = !engine.setBuiltInPluginParam(trackId, "track", 0, "fuzzDrive", std::numeric_limits::quiet_NaN()); + clampsPassed = clampsPassed && malformedRejected + && valueMatches(bridgeValue(engine.getBuiltInPluginState(trackId, "track", 0), "fuzzDrive"), 0.73); + } + if (trackAdded) (void) engine.removeTrack(trackId); + auto* result = new juce::DynamicObject(); + result->setProperty("rackAdded", rackAdded); + result->setProperty("schemaAndDefaultsPassed", defaultPassed); + result->setProperty("parameterClampsAndMalformedRejectionPassed", clampsPassed); + result->setProperty("bridgeStateRoundtripPassed", roundtripPassed); + add(checks, "nam_fuzz_parameter_bridge_contract", defaultPassed && clampsPassed && roundtripPassed ? "pass" : "fail", + "Native Device Controls publish six fuzz parameters, clamp finite extremes, quantize the voice enum, reject malformed changes and preserve saved values through bridge state restoration; schema version remains 1.", juce::var(result)); + } +}; diff --git a/Source/NAMFuzzSignalRegression.h b/Source/NAMFuzzSignalRegression.h new file mode 100644 index 0000000..0d59157 --- /dev/null +++ b/Source/NAMFuzzSignalRegression.h @@ -0,0 +1,499 @@ +#pragma once + +#include "BuiltInEffects2.h" +#include "NAMFuzz.h" +#include +#include +#include +#include + +// Native-level transfer diagnostics. Deliberately has no reference-pedal or +// subjective quality threshold, and never reads live input or opens a device. +class NAMFuzzSignalRegression final +{ +public: + static juce::var runPartitionSmoke() + { + // A bounded default-suite witness for the actual downstream defect. + // The full profile/voice/mask/gate/oversampling grid remains opt-in. + const auto source = makeSource(true, -72.0f); + bool passed = true; + juce::Array rows; + for (const int factor : { 2, 4, 8 }) + { + auto regularRack = makeRack(true, 0, 0.5f, 5, -100.0f, 80.0f, factor); + auto irregularRack = makeRack(true, 0, 0.5f, 5, -100.0f, 80.0f, factor); + const auto regular = renderRack(source, *regularRack); + const auto irregular = renderRack(source, *irregularRack, true); + auto comparison = partitionDiagnostics(regular, irregular); + const bool readbackPassed = static_cast(regularRack->getDiagnosticState()["embeddedDriveActiveOversamplingFactor"]) == factor + && static_cast(irregularRack->getDiagnosticState()["embeddedDriveActiveOversamplingFactor"]) == factor; + comparison.getDynamicObject()->setProperty("oversamplingFactor", factor); + comparison.getDynamicObject()->setProperty("activeOversamplingReadbackPassed", readbackPassed); + passed = readbackPassed && static_cast(comparison["passed"]) && passed; + rows.add(comparison); + } + auto* result = new juce::DynamicObject(); + result->setProperty("id", "nam_fuzz_gain_stack_partition_smoke"); + result->setProperty("status", passed ? "pass" : "fail"); + result->setProperty("detail", "Bass Vintage Fuzz into Distortion at 2x/4x/8x must remain finite and preserve the seeded noise/played-note output within 1e-5 across fixed 128 and irregular 1/7/16/31/64/127/128 callbacks. These are measured numerical-precision witnesses, not tone-quality assertions."); + result->setProperty("value", rows); + return result; + } + static juce::Array run() + { + juce::Array checks; + if (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_SIGNAL_AUDIT", {}) != "1") + return checks; + bool contracts = true; + juce::Array standalone, lowLevel, cascades; + for (const bool bass : { false, true }) + for (int voice = 0; voice < 3; ++voice) + for (const float drive : { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f }) + { + for (const float tone : { 0.0f, 0.5f, 1.0f }) + for (const float noiseDb : { -90.0f, -72.0f, -54.0f }) + { + NAMFuzz::Parameters parameters; + parameters.enabled = true; parameters.bass = bass; parameters.voice = voice; + parameters.drive = drive; parameters.tone = tone; + auto source = makeSource(bass, noiseDb); + auto output = renderFuzz(source, parameters); + auto row = describe(bass, voice, drive, tone); + row.getDynamicObject()->setProperty("inputNoiseTargetRmsDbFS", noiseDb); + row.getDynamicObject()->setProperty("measurements", measure(source, output, contracts)); + standalone.add(row); + } + for (const float inputDb : { -72.0f, -48.0f, -24.0f }) + { + NAMFuzz::Parameters parameters; + parameters.enabled = true; parameters.bass = bass; parameters.voice = voice; + parameters.drive = drive; parameters.tone = 0.5f; + juce::AudioBuffer source(1, static_cast(rate / 2)); + const double frequency = bass ? 55.0 : 220.0; + const float peak = juce::Decibels::decibelsToGain(inputDb); + for (int sample = 0; sample < source.getNumSamples(); ++sample) + source.setSample(0, sample, peak * static_cast(std::sin( + juce::MathConstants::twoPi * frequency * sample / rate))); + auto output = renderFuzz(source, parameters); + auto row = describe(bass, voice, drive, 0.5f); + row.getDynamicObject()->setProperty("frequencyHz", frequency); + row.getDynamicObject()->setProperty("inputPeakDbFS", inputDb); + const double inputRms = rms(source, 0.25, 0.5), outputRms = rms(output, 0.25, 0.5); + row.getDynamicObject()->setProperty("inputRmsDbFS", db(inputRms)); + row.getDynamicObject()->setProperty("outputRmsDbFS", db(outputRms)); + row.getDynamicObject()->setProperty("rmsGainDb", db(outputRms / juce::jmax(1.0e-30, inputRms))); + row.getDynamicObject()->setProperty("harmonics", harmonics(output, frequency)); + contracts = finite(output) && contracts; + lowLevel.add(row); + } + } + for (const bool bass : { false, true }) + for (const float drive : { 0.5f, 1.0f }) + for (int mask = 0; mask < 8; ++mask) + for (int voice = 0; voice < ((mask & 1) != 0 ? 3 : 1); ++voice) + { + auto source = makeSource(bass, -72.0f); + auto rack = makeRack(bass, voice, drive, mask); + juce::AudioBuffer output; output.makeCopyOf(source); + juce::MidiBuffer midi; + for (int start = 0; start < output.getNumSamples(); start += 128) + { + float* pointer = output.getWritePointer(0, start); + juce::AudioBuffer block(&pointer, 1, juce::jmin(128, output.getNumSamples() - start)); + rack->processBlock(block, midi); + } + auto row = describe(bass, voice, drive, 0.5f); + row.getDynamicObject()->setProperty("enabledMaskFuzzDriveDistortion", mask); + row.getDynamicObject()->setProperty("measurements", measure(source, output, contracts)); + row.getDynamicObject()->setProperty("rackDiagnostics", rack->getDiagnosticState()); + cascades.add(row); + } + auto* report = new juce::DynamicObject(); + report->setProperty("sampleRate", rate); + report->setProperty("standalone", standalone); + report->setProperty("lowLevelSine", lowLevel); + report->setProperty("cascades", cascades); + report->setProperty("calibrationReferenceDbu", 12.0); + report->setProperty("levelDb", 0.0); + report->setProperty("cleanMix", 0.0); + report->setProperty("measurement", "Unweighted native-level RMS/peaks; no normalization, adaptive matching or extra output trim. Manufacturer A-weighted analog dBV noise specifications are not directly comparable."); + report->setProperty("signal", "250ms digital silence, 500ms seeded uniform noise, 500ms decaying harmonic note plus that noise, 500ms noise, 750ms digital silence. Noise targets are RMS; a shared seed makes before/after reproducible."); + report->setProperty("scope", "Original three voices, both profiles, Drive at0/.25/.5/.75/1, Tone endpoints/midpoint, three input noise levels, low-level sine transfer with harmonics2..15 and all eight pedal masks. Cascades use 4x Drive/Distortion oversampling, Heavy Distortion mode, gates disabled, no amp/cab/octaves/time effects. This is not a complete amplified guitar rig or every knob combination."); + auto* contract = new juce::DynamicObject(); + contract->setProperty("id", "nam_fuzz_signal_contracts"); + contract->setProperty("status", contracts ? "pass" : "fail"); + contract->setProperty("detail", "Finite output and exact fresh digital silence are asserted; all noise gain, note envelope, silence decay and cascade measurements remain diagnostic_only."); + contract->setProperty("value", report); + checks.add(contract); + auto* diagnostic = new juce::DynamicObject(); + diagnostic->setProperty("id", "nam_fuzz_noise_and_transfer_measurements"); + diagnostic->setProperty("status", "diagnostic_only"); + diagnostic->setProperty("detail", "Input-noise amplification, compression, bright harmonics and downstream saturation require interpretation and exact-artifact audition. No noise threshold or pedal resemblance is certified."); + checks.add(diagnostic); + checks.addArray(globalGateContracts()); + return checks; + } + +private: + static constexpr double rate = 48000.0; + static juce::var describe(bool bass, int voice, float drive, float tone) + { + auto* result = new juce::DynamicObject(); + result->setProperty("bass", bass); result->setProperty("voice", voice); + result->setProperty("drive", drive); result->setProperty("tone", tone); + return result; + } + static bool finite(const juce::AudioBuffer& buffer) + { + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + if (!std::isfinite(buffer.getSample(0, sample))) return false; + return true; + } + static double rms(const juce::AudioBuffer& buffer, double from, double to) + { + const int begin = static_cast(from * rate), end = juce::jmin(buffer.getNumSamples(), static_cast(to * rate)); + double sum = 0.0; + for (int sample = begin; sample < end; ++sample) + { const double value = buffer.getSample(0, sample); sum += value * value; } + return std::sqrt(sum / juce::jmax(1, end - begin)); + } + static double db(double value) { return 20.0 * std::log10(juce::jmax(1.0e-15, value)); } + static juce::var harmonics(const juce::AudioBuffer& buffer, double frequency) + { + // The final 200ms contain exactly 11/44 periods at55/220Hz. No taper, + // gain fitting or phase alignment is needed for harmonic magnitudes. + constexpr int begin = 14400, count = 9600; + double fundamentalPower = 0.0, harmonicPower = 0.0; + juce::Array amplitudes; + for (int harmonic = 1; harmonic <= 15; ++harmonic) + { + double real = 0.0, imaginary = 0.0; + for (int sample = 0; sample < count; ++sample) + { + const double phase = juce::MathConstants::twoPi * frequency * harmonic * sample / rate; + const double value = buffer.getSample(0, begin + sample); + real += value * std::cos(phase); imaginary += value * std::sin(phase); + } + const double amplitude = 2.0 * std::sqrt(real * real + imaginary * imaginary) / count; + amplitudes.add(db(amplitude)); + if (harmonic == 1) fundamentalPower = amplitude * amplitude; + else harmonicPower += amplitude * amplitude; + } + auto* result = new juce::DynamicObject(); + result->setProperty("peakDbFSByHarmonic1Through15", amplitudes); + result->setProperty("fundamentalPresent", fundamentalPower > 1.0e-24); + if (fundamentalPower > 1.0e-24) + result->setProperty("harmonics2Through15OverFundamentalDb", db(std::sqrt(harmonicPower / fundamentalPower))); + result->setProperty("interpretation", "diagnostic_only: spectral transfer character, not an aliasing measurement or a fuzz-quality score."); + return result; + } + static juce::AudioBuffer makeSource(bool bass, float noiseDb) + { + juce::AudioBuffer result(1, static_cast(2.5 * rate)); result.clear(); + std::uint32_t seed = 0x8a5cd789u; + const double noiseScale = std::sqrt(3.0) * juce::Decibels::decibelsToGain(noiseDb); + for (int sample = 12000; sample < 84000; ++sample) + { + seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; + double value = (static_cast(seed) / 2147483648.0 - 1.0) * noiseScale; + if (sample >= 36000 && sample < 60000) + { + const double time = (sample - 36000) / rate; + const double phase = juce::MathConstants::twoPi * (bass ? 55.0 : 220.0) * time; + value += juce::Decibels::decibelsToGain(-18.0) * (1.0 - std::exp(-time / 0.002)) + * std::exp(-time / 0.18) * (std::sin(phase) + 0.25 * std::sin(2.0 * phase) + + 0.12 * std::sin(3.0 * phase)); + } + result.setSample(0, sample, static_cast(value)); + } + return result; + } + static juce::AudioBuffer renderFuzz(const juce::AudioBuffer& source, + const NAMFuzz::Parameters& parameters, bool irregular = false) + { + juce::AudioBuffer result; result.makeCopyOf(source); + NAMFuzz fuzz; fuzz.prepare(rate, parameters); + constexpr std::array partition { 1, 7, 16, 31, 64, 127, 128 }; + size_t partitionIndex = 0; + for (int start = 0; start < result.getNumSamples();) + { + const int count = juce::jmin(result.getNumSamples() - start, + irregular ? partition[partitionIndex++ % partition.size()] : 128); + float* pointer = result.getWritePointer(0, start); + juce::AudioBuffer block(&pointer, 1, count); + fuzz.processBlock(block); + start += count; + } + return result; + } + static juce::var measure(const juce::AudioBuffer& input, const juce::AudioBuffer& output, bool& contracts) + { + const bool valid = finite(output); + const float initialSilencePeak = output.getMagnitude(0, 12000); + contracts = valid && initialSilencePeak == 0.0f && contracts; + auto* result = new juce::DynamicObject(); + result->setProperty("finite", valid); result->setProperty("initialSilencePeak", initialSilencePeak); + result->setProperty("peakDbFS", db(output.getMagnitude(0, output.getNumSamples()))); + for (const auto& window : std::array, 5> {{{0.5, 0.75}, {0.76, 0.85}, {1.1, 1.25}, {1.5, 1.75}, {2.3, 2.5}}}) + { + const auto label = "window" + juce::String(window[0], 2) + "To" + juce::String(window[1], 2); + const double inputRms = rms(input, window[0], window[1]); + const double outputRms = rms(output, window[0], window[1]); + auto* reading = new juce::DynamicObject(); + reading->setProperty("inputRmsDbFS", db(inputRms)); reading->setProperty("outputRmsDbFS", db(outputRms)); + if (inputRms > 0.0) reading->setProperty("rmsGainDb", db(outputRms / inputRms)); + result->setProperty(label, reading); + } + return result; + } + static juce::AudioBuffer renderRack(const juce::AudioBuffer& source, + OpenStudioNAMRack& rack, bool irregular = false) + { + juce::AudioBuffer result; result.makeCopyOf(source); + juce::MidiBuffer midi; + constexpr std::array partition { 1, 7, 16, 31, 64, 127, 128 }; + int position = 0; + size_t partitionIndex = 0; + while (position < result.getNumSamples()) + { + const int count = juce::jmin(result.getNumSamples() - position, + irregular ? partition[partitionIndex++ % partition.size()] : 128); + float* pointer = result.getWritePointer(0, position); + juce::AudioBuffer block(&pointer, 1, count); + rack.processBlock(block, midi); + position += count; + } + return result; + } + static juce::var partitionDiagnostics(const juce::AudioBuffer& regular, + const juce::AudioBuffer& irregular) + { + double maximum = 0.0; + int maximumSample = -1, firstAboveTolerance = -1, lastAboveTolerance = -1, countAboveTolerance = 0; + std::array windowMaxima {}; + for (int sample = 0; sample < regular.getNumSamples(); ++sample) + { + const double difference = std::abs(static_cast(regular.getSample(0, sample)) + - irregular.getSample(0, sample)); + if (difference > maximum) { maximum = difference; maximumSample = sample; } + const size_t window = sample < 12000 ? 0 : sample < 36000 ? 1 : sample < 60000 ? 2 : 3; + windowMaxima[window] = juce::jmax(windowMaxima[window], difference); + if (difference > 1.0e-5) + { + if (firstAboveTolerance < 0) firstAboveTolerance = sample; + lastAboveTolerance = sample; ++countAboveTolerance; + } + } + auto* result = new juce::DynamicObject(); + result->setProperty("maximumDifference", maximum); + result->setProperty("maximumDifferenceSample", maximumSample); + result->setProperty("maximumDifferenceSeconds", maximumSample / rate); + result->setProperty("firstSampleAbove1eMinus5", firstAboveTolerance); + result->setProperty("lastSampleAbove1eMinus5", lastAboveTolerance); + result->setProperty("samplesAbove1eMinus5", countAboveTolerance); + result->setProperty("digitalSilenceMaximumDifference", windowMaxima[0]); + result->setProperty("preNoteNoiseMaximumDifference", windowMaxima[1]); + result->setProperty("playedNoteMaximumDifference", windowMaxima[2]); + result->setProperty("postNoteNoiseMaximumDifference", windowMaxima[3]); + result->setProperty("finite", finite(regular) && finite(irregular)); + result->setProperty("passed", finite(regular) && finite(irregular) && maximum < 1.0e-5); + return result; + } + static juce::Array globalGateContracts() + { + juce::Array checks, rows, partitionRows, localization; + bool passed = true, partitionPassed = true; + for (const bool bass : { false, true }) + { + // Retain the same note and noise seed as the transfer fixture, + // then continue noise long enough to observe the slow release. + const auto original = makeSource(bass, -72.0f); + juce::AudioBuffer source(1, static_cast(3.0 * rate)); + source.copyFrom(0, 0, original, 0, 0, original.getNumSamples()); + for (int sample = 84000; sample < source.getNumSamples(); ++sample) + source.setSample(0, sample, original.getSample(0, 12000 + (sample - 84000) % 24000)); + for (int mask = 0; mask < 8; ++mask) + for (int voice = 0; voice < ((mask & 1) != 0 ? 3 : 1); ++voice) + { + auto ungatedRack = makeRack(bass, voice, 0.5f, mask); + const auto ungated = renderRack(source, *ungatedRack); + auto irregularUngatedRack = makeRack(bass, voice, 0.5f, mask); + const auto irregularUngated = renderRack(source, *irregularUngatedRack, true); + auto partitionRow = describe(bass, voice, 0.5f, 0.5f); + partitionRow.getDynamicObject()->setProperty("enabledMaskFuzzDriveDistortion", mask); + const auto partitionEvidence = partitionDiagnostics(ungated, irregularUngated); + partitionRow.getDynamicObject()->setProperty("comparison", partitionEvidence); + partitionPassed = static_cast(partitionEvidence["passed"]) && partitionPassed; + partitionRows.add(partitionRow); + auto openRack = makeRack(bass, voice, 0.5f, mask, -80.0f, 80.0f); + const auto open = renderRack(source, *openRack); + for (const float releaseMs : { 20.0f, 180.0f }) + { + auto closedRack = makeRack(bass, voice, 0.5f, mask, -60.0f, releaseMs); + const auto closed = renderRack(source, *closedRack); + auto partitionedRack = makeRack(bass, voice, 0.5f, mask, -60.0f, releaseMs); + const auto partitioned = renderRack(source, *partitionedRack, true); + const double referenceNoise = rms(ungated, 2.75, 3.0); + const double closedNoise = rms(closed, 2.75, 3.0); + const double openNoise = rms(open, 2.75, 3.0); + const double referenceNote = rms(ungated, 1.1, 1.2); + const double closedNote = rms(closed, 1.1, 1.2); + double maximumPartitionDifference = 0.0, maximumUngatedPartitionDifference = 0.0; + double maximumGateEnvelopePartitionDifference = 0.0, maximumReleaseStep = 0.0; + double previousRatio = 1.0; + int previousSample = 60000, releaseSamples = 0, envelopeComparisonSamples = 0; + int crossed90 = -1, crossed10 = -1; + bool ratiosBounded = true; + for (int sample = 0; sample < source.getNumSamples(); ++sample) + { + maximumPartitionDifference = juce::jmax(maximumPartitionDifference, + std::abs(static_cast(closed.getSample(0, sample)) - partitioned.getSample(0, sample))); + const double reference = ungated.getSample(0, sample); + const double irregularReference = irregularUngated.getSample(0, sample); + maximumUngatedPartitionDifference = juce::jmax(maximumUngatedPartitionDifference, + std::abs(reference - irregularReference)); + if (std::abs(reference) >= 1.0e-5 && std::abs(irregularReference) >= 1.0e-5) + { + // Reconstruct each gate envelope against the same + // callback layout. The nonlinear stack's own raw + // partition differences are reported separately; + // they must not be mistaken for a gate discontinuity. + maximumGateEnvelopePartitionDifference = juce::jmax(maximumGateEnvelopePartitionDifference, + std::abs(closed.getSample(0, sample) / reference + - partitioned.getSample(0, sample) / irregularReference)); + ++envelopeComparisonSamples; + } + if (sample < 60000 || std::abs(reference) < 1.0e-5) continue; + const double ratio = closed.getSample(0, sample) / reference; + ratiosBounded = ratiosBounded && ratio >= -1.0e-4 && ratio <= 1.0001; + if (previousSample < sample) + maximumReleaseStep = juce::jmax(maximumReleaseStep, + std::abs(ratio - previousRatio) / (sample - previousSample)); + if (crossed90 < 0 && ratio < 0.9) crossed90 = sample; + if (crossed90 >= 0 && crossed10 < 0 && ratio < 0.1) crossed10 = sample; + previousRatio = ratio; previousSample = sample; ++releaseSamples; + } + const double fadeMilliseconds = crossed90 >= 0 && crossed10 >= crossed90 + ? 1000.0 * (crossed10 - crossed90) / rate : -1.0; + // Gated Fuzz can itself silence the fixture. Its internal + // gate is a legitimate zero reference, not evidence that + // the global gate attenuated an audible downstream stack. + const bool observableIdle = referenceNoise > 1.0e-12; + const bool idleControlled = !observableIdle || closedNoise < referenceNoise * 0.01; + const bool belowNoiseThresholdOpen = !observableIdle + || std::abs(openNoise / referenceNoise - 1.0) < 1.0e-4; + const bool noteOpened = referenceNote > 1.0e-6 + && std::abs(closedNote / referenceNote - 1.0) < 1.0e-4; + const bool observableRelease = releaseSamples > 1000 && crossed90 >= 0 && crossed10 >= 0; + const bool releaseContinuous = !observableIdle || (observableRelease && ratiosBounded + && maximumReleaseStep < 2.0 / (rate * releaseMs * 0.001) + 2.0e-4 + && fadeMilliseconds > 1.5 * releaseMs && fadeMilliseconds < 3.0 * releaseMs); + const bool rowPassed = finite(ungated) && finite(irregularUngated) + && finite(closed) && finite(open) && finite(partitioned) + && idleControlled && belowNoiseThresholdOpen && noteOpened && releaseContinuous + && envelopeComparisonSamples > 1000 && maximumGateEnvelopePartitionDifference < 1.0e-5; + passed = rowPassed && passed; + auto row = describe(bass, voice, 0.5f, 0.5f); + auto* value = row.getDynamicObject(); + value->setProperty("enabledMaskFuzzDriveDistortion", mask); + value->setProperty("releaseMs", releaseMs); value->setProperty("passed", rowPassed); + value->setProperty("ungatedIdleRmsDbFS", db(referenceNoise)); + value->setProperty("thresholdMinus80IdleRmsDbFS", db(openNoise)); + value->setProperty("thresholdMinus60IdleRmsDbFS", db(closedNoise)); + value->setProperty("idleAttenuationObservable", observableIdle); + value->setProperty("idleControlled", idleControlled); + value->setProperty("belowNoiseThresholdOpen", belowNoiseThresholdOpen); + value->setProperty("noteOpened", noteOpened); + value->setProperty("ungatedLateNoteRmsDbFS", db(referenceNote)); + value->setProperty("gatedLateNoteRmsDbFS", db(closedNote)); + value->setProperty("releaseObservable", observableRelease); + value->setProperty("releaseContinuous", releaseContinuous); + value->setProperty("release90To10Milliseconds", fadeMilliseconds); + value->setProperty("maximumReleaseGainStepPerSample", maximumReleaseStep); + value->setProperty("maximumPartitionDifference", maximumPartitionDifference); + value->setProperty("maximumUngatedPartitionDifference", maximumUngatedPartitionDifference); + value->setProperty("gateEnvelopeComparisonSamples", envelopeComparisonSamples); + value->setProperty("maximumGateEnvelopePartitionDifference", maximumGateEnvelopePartitionDifference); + value->setProperty("rawPartitionDifferenceInterpretation", "diagnostic_only: raw whole-rack partition differences include nonlinear gain-pedal processing. Gate-envelope parity is independently checked against each matching ungated callback layout."); + rows.add(row); + } + } + } + auto* result = new juce::DynamicObject(); + result->setProperty("id", "nam_fuzz_explicit_global_gate_contracts"); + result->setProperty("status", passed ? "pass" : "fail"); + result->setProperty("detail", "All eight gain-pedal masks, active Fuzz voices and both profiles use seeded -72 dBFS RMS noise. Explicit threshold -60 is above its peak; threshold -80 remains open. Both Release20/180ms must preserve an above-threshold late note, attenuate observable steady idle by at least40dB, close continuously and preserve the gate envelope across irregular callbacks. Already-silent Gated voice cases do not prove idle attenuation or release. Raw whole-rack partition differences remain diagnostic_only. This verifies existing gate behavior, not subjective sustain or tone quality."); + result->setProperty("value", rows); + checks.add(result); + + // Keep the independently discovered whole-rack partition requirement + // visible. Localize its worst fixture without altering the production + // path: fixed prepared Fuzz audio distinguishes its state from the + // downstream island. Exercise every supported shared oversampling rate. + const auto source = makeSource(true, -72.0f); + NAMFuzz::Parameters parameters; parameters.enabled = true; parameters.bass = true; + const auto fuzzAudio = renderFuzz(source, parameters); + const auto irregularFuzzAudio = renderFuzz(source, parameters, true); + auto fuzzComparison = partitionDiagnostics(fuzzAudio, irregularFuzzAudio); + fuzzComparison.getDynamicObject()->setProperty("route", "standalone_bass_vintage_fuzz"); + localization.add(fuzzComparison); + partitionPassed = static_cast(fuzzComparison["passed"]) && partitionPassed; + for (const int oversampling : { 2, 4, 8 }) + for (const bool prerenderedFuzz : { false, true }) + { + const auto& input = prerenderedFuzz ? fuzzAudio : source; + const int mask = prerenderedFuzz ? 4 : 5; + auto regularRack = makeRack(true, 0, 0.5f, mask, -100.0f, 80.0f, oversampling); + auto irregularRack = makeRack(true, 0, 0.5f, mask, -100.0f, 80.0f, oversampling); + const auto regular = renderRack(input, *regularRack); + const auto irregular = renderRack(input, *irregularRack, true); + auto comparison = partitionDiagnostics(regular, irregular); + comparison.getDynamicObject()->setProperty("route", prerenderedFuzz + ? "identical_prerendered_fuzz_into_distortion_only" : "rack_fuzz_into_distortion"); + const int regularActive = static_cast(regularRack->getDiagnosticState()["embeddedDriveActiveOversamplingFactor"]); + const int irregularActive = static_cast(irregularRack->getDiagnosticState()["embeddedDriveActiveOversamplingFactor"]); + const bool readbackPassed = regularRack->getEmbeddedDriveOversamplingFactor() == oversampling + && irregularRack->getEmbeddedDriveOversamplingFactor() == oversampling + && regularActive == oversampling && irregularActive == oversampling; + comparison.getDynamicObject()->setProperty("requestedEmbeddedOversamplingFactor", oversampling); + comparison.getDynamicObject()->setProperty("regularActiveOversamplingFactor", regularActive); + comparison.getDynamicObject()->setProperty("irregularActiveOversamplingFactor", irregularActive); + comparison.getDynamicObject()->setProperty("oversamplingReadbackPassed", readbackPassed); + localization.add(comparison); + partitionPassed = readbackPassed && static_cast(comparison["passed"]) && partitionPassed; + } + auto* partitionResult = new juce::DynamicObject(); + partitionResult->setProperty("id", "nam_fuzz_gain_stack_partition_contracts"); + partitionResult->setProperty("status", partitionPassed ? "pass" : "fail"); + partitionResult->setProperty("detail", "Fixed128 versus irregular1/7/16/31/64/127/128 callbacks must preserve native gain-stack output within1e-5. This requirement is separate from the clean-keyed global gate. Maxima and exact frame windows identify whether differences occur during silence, played notes or idle noise; localization keeps one identical prepared Fuzz input for downstream isolation. No preferred tone or alias audibility is asserted."); + auto* partitionValue = new juce::DynamicObject(); + partitionValue->setProperty("allGainMasks", partitionRows); + partitionValue->setProperty("bassVintageDistortionLocalization", localization); + partitionResult->setProperty("value", partitionValue); + checks.add(partitionResult); + return checks; + } + static std::unique_ptr makeRack(bool bass, int voice, float drive, int mask, + float gateThresholdDb = -100.0f, float gateReleaseMs = 80.0f, int oversamplingFactor = 4) + { + auto rack = std::make_unique(); + rack->setRoutedInputChannelCount(1); rack->instrumentProfile.store(bass ? 1.0f : 0.0f); + rack->calibrationReferenceDbu.store(12.0f); rack->inputTrimDb.store(0.0f); rack->outputTrimDb.store(0.0f); + rack->gateThresholdDb.store(gateThresholdDb); rack->gateReleaseMs.store(gateReleaseMs); + rack->compressorEnabled.store(0.0f); rack->octaverEnabled.store(0.0f); + rack->fuzzEnabled.store((mask & 1) != 0 ? 1.0f : 0.0f); rack->fuzzVoice.store(static_cast(voice)); + rack->fuzzDrive.store(drive); rack->fuzzTone.store(0.5f); rack->fuzzLevelDb.store(0.0f); rack->fuzzCleanMix.store(0.0f); + rack->preEqEnabled.store(0.0f); rack->setEmbeddedDriveOversamplingFactor(oversamplingFactor); + rack->precisionDriveEnabled.store((mask & 2) != 0 ? 1.0f : 0.0f); + rack->precisionDriveDrive.store(drive); rack->precisionDriveGate.store(0.0f); rack->precisionDriveVolumeDb.store(0.0f); + rack->chaosEnabled.store((mask & 4) != 0 ? 1.0f : 0.0f); rack->chaosMode.store(0.0f); + rack->chaosDrive.store(drive); rack->chaosTone.store(0.5f); rack->chaosGate.store(0.0f); + rack->chaosMix.store(1.0f); rack->chaosLevelDb.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); + rack->setPlayConfigDetails(1, 1, rate, 128); rack->prepareToPlay(rate, 128); + return rack; + } +}; diff --git a/Source/NAMLiveCallbackProbe.h b/Source/NAMLiveCallbackProbe.h new file mode 100644 index 0000000..24bfc88 --- /dev/null +++ b/Source/NAMLiveCallbackProbe.h @@ -0,0 +1,543 @@ +#pragma once +#include "AudioEngine.h" +#include "AudioDeviceConfiguration.h" +#include "ASIOCapabilities.h" +#include "AppPaths.h" +#include "BuiltInEffects2.h" +#include +#include +#include +#include +#include + +// Explicit opt-in hardware diagnostic. It never exposes physical input samples +// to the engine, and the device receives silence regardless of the engine output. +namespace NAMLiveCallbackProbe +{ +constexpr int scratchCapacity = 8192; +struct Options +{ + int trackCount = 2; + int oversamplingFactor = 4; + double sampleRate = 44100.0; + int warmupSeconds = 10, measurementSeconds = 10; + std::vector buffers {8, 16, 128}; + bool recording = false; + int workerCount = -1; + int workerSpinMicroseconds = 0; + int recoveryGraceMicroseconds = 0; +}; + +inline bool validOptions(const Options& value) +{ + if (value.trackCount < 1 || value.trackCount > 64 || !std::isfinite(value.sampleRate) + || (value.oversamplingFactor != 2 && value.oversamplingFactor != 4 && value.oversamplingFactor != 8) + || (value.sampleRate != 44100.0 && value.sampleRate != 48000.0 && value.sampleRate != 88200.0 + && value.sampleRate != 96000.0 && value.sampleRate != 176400.0 && value.sampleRate != 192000.0) + || value.warmupSeconds < 0 || value.warmupSeconds > 60 || value.measurementSeconds < 1 || value.measurementSeconds > 1800 + || value.buffers.empty() || value.buffers.size() > 16 || value.workerCount < -1 || value.workerCount > 127 + || value.workerSpinMicroseconds < 0 || value.workerSpinMicroseconds > 2000 + || value.recoveryGraceMicroseconds < 0 || value.recoveryGraceMicroseconds > 2000) return false; + std::vector sorted = value.buffers; + std::sort(sorted.begin(), sorted.end()); + return sorted.front() >= 8 && sorted.back() <= scratchCapacity + && std::adjacent_find(sorted.begin(), sorted.end()) == sorted.end(); +} + +class Callback final : public juce::AudioIODeviceCallback +{ +public: + struct Record { juce::int64 frame, arrival, duration; int samples; }; + Callback(AudioEngine& owner, const Options& requested) + : engine(owner), options(requested), cycleFrames(static_cast(options.sampleRate * 2.0)), + input(options.trackCount, cycleFrames + scratchCapacity), output(2, scratchCapacity), records(65536), missedRecords(4096) + { + for (int channel = 0; channel < options.trackCount; ++channel) + { + // Half-Hz frequencies give an exactly two-second periodic fixture. + // Each track has its own frequency and phase, even when presets repeat. + const double frequency = (channel % 2 == 0 ? 82.5 : 31.0) + 1.5 * (channel / 2); + for (int frame = 0; frame < cycleFrames; ++frame) + { + const double phase = juce::MathConstants::twoPi * (frequency * frame / options.sampleRate + 0.11 * channel); + input.setSample(channel, frame, static_cast(0.15 * std::sin(phase) + 0.035 * std::sin(7.0 * phase))); + } + input.copyFrom(channel, cycleFrames, input, channel, 0, scratchCapacity); + } + } + void begin(int block) + { + requestedBlock = block; frames = 0; count = 0; missedCount = 0; previousArrival = 0; + measuredBlocks = measuredFrames = deadlineMisses = lateArrivals = arrivalCount = totalTicks = maximumTicks = maximumArrival = 0; + durationHistogram.fill(0); arrivalHistogram.fill(0); + warmupFrames = static_cast(options.sampleRate * options.warmupSeconds); + targetFrames = static_cast(options.sampleRate * options.measurementSeconds); + const auto expectedBlocks = (targetFrames + block - 1) / block; + traceStride = std::max(1, (expectedBlocks + static_cast(records.size()) - 1) / static_cast(records.size())); + budgetTicks = static_cast(juce::Time::getHighResolutionTicksPerSecond()) * block / options.sampleRate; + peak = 0.0f; nonFinite = 0; clipped = 0; starts = 0; + invalid.store(false); finished.store(false); enabled.store(false); + } + void start() noexcept { enabled.store(true, std::memory_order_release); } + juce::int64 processedFrames() const noexcept { return frames; } // Control thread after callback removal. + float inputAt(int channel, juce::int64 frame) const { return input.getSample(channel, static_cast(frame % cycleFrames)); } + void audioDeviceAboutToStart(juce::AudioIODevice* device) override + { + if (++starts != 1 || device == nullptr || device->getCurrentSampleRate() != options.sampleRate + || device->getCurrentBufferSizeSamples() != requestedBlock) + invalid.store(true, std::memory_order_relaxed); + engine.audioDeviceAboutToStart(device); + } + void audioDeviceStopped() override { engine.audioDeviceStopped(); } + void audioDeviceError(const juce::String& error) override + { invalid.store(true, std::memory_order_relaxed); engine.audioDeviceError(error); } + void audioDeviceIOCallbackWithContext(const float* const* physicalInputs, int inputCount, + float* const* physicalOutputs, int outputCount, int samples, + const juce::AudioIODeviceCallbackContext& context) override + { + const auto arrival = juce::Time::getHighResolutionTicks(); + juce::ignoreUnused(physicalInputs, inputCount); + for (int channel = 0; channel < outputCount; ++channel) + if (physicalOutputs[channel] != nullptr) juce::FloatVectorOperations::clear(physicalOutputs[channel], samples); + if (!enabled.load(std::memory_order_acquire) || finished.load(std::memory_order_relaxed)) return; + if (samples != requestedBlock || samples <= 0 || samples > scratchCapacity || outputCount != 2) + { invalid.store(true, std::memory_order_relaxed); finished.store(true, std::memory_order_release); return; } + const int position = static_cast(frames % cycleFrames); + std::array synthetic {}; + for (int channel = 0; channel < options.trackCount; ++channel) synthetic[static_cast(channel)] = input.getReadPointer(channel, position); + float* scratch[] { output.getWritePointer(0), output.getWritePointer(1) }; + output.clear(0, samples); + const auto before = juce::Time::getHighResolutionTicks(); + engine.audioDeviceIOCallbackWithContext(synthetic.data(), options.trackCount, scratch, 2, samples, context); + const auto duration = juce::Time::getHighResolutionTicks() - before; + if (frames >= warmupFrames && frames < warmupFrames + targetFrames) + { + const auto interval = previousArrival == 0 ? 0 : arrival - previousArrival; + const Record record { frames, interval, duration, samples }; + if (measuredBlocks % traceStride == 0 && count < records.size()) records[count++] = record; + ++measuredBlocks; measuredFrames += samples; totalTicks += duration; maximumTicks = juce::jmax(maximumTicks, duration); + ++durationHistogram[histogramBin(duration)]; + if (duration > budgetTicks) + { ++deadlineMisses; if (missedCount < missedRecords.size()) missedRecords[missedCount++] = record; } + if (interval > 0) + { + ++arrivalCount; ++arrivalHistogram[histogramBin(interval)]; maximumArrival = juce::jmax(maximumArrival, interval); + if (interval > budgetTicks * 1.5) ++lateArrivals; + } + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < samples; ++sample) + { + const float value = scratch[channel][sample]; + if (!std::isfinite(value)) ++nonFinite; + else { peak = juce::jmax(peak, std::abs(value)); if (std::abs(value) >= 1.0f) ++clipped; } + } + } + previousArrival = arrival; frames += samples; + if (frames >= warmupFrames + targetFrames) finished.store(true, std::memory_order_release); + } + juce::var report(const juce::File& csv) const // Only after removeAudioCallback has joined callbacks. + { + auto* result = new juce::DynamicObject(); + juce::String text("kind,frame,samples,engine_microseconds,arrival_interval_microseconds\n"); + text.preallocateBytes(112 * (count + missedCount) + 128); + const double budget = requestedBlock * 1.0e6 / options.sampleRate; + const auto append = [&](const Record& row, const char* kind) + { + const double elapsed = juce::Time::highResolutionTicksToSeconds(row.duration) * 1.0e6; + const double interval = juce::Time::highResolutionTicksToSeconds(row.arrival) * 1.0e6; + text << kind << ',' << row.frame << ',' << row.samples << ',' << juce::String(elapsed, 3) << ',' << juce::String(interval, 3) << '\n'; + }; + for (size_t index = 0; index < count; ++index) append(records[index], "uniform"); + for (size_t index = 0; index < missedCount; ++index) append(missedRecords[index], "deadline_miss"); + const bool valid = !invalid.load() && finished.load() && starts == 1 && measuredBlocks > 0 && nonFinite == 0 + && measuredFrames >= targetFrames - requestedBlock && peak > 0.000001f; + result->setProperty("processingValid", valid); result->setProperty("requestedBufferSamples", requestedBlock); + result->setProperty("measuredBlocks", measuredBlocks); result->setProperty("measuredFrames", measuredFrames); result->setProperty("processedFrames", frames); + result->setProperty("budgetMicroseconds", budget); result->setProperty("meanMicroseconds", measuredBlocks == 0 ? 0.0 : juce::Time::highResolutionTicksToSeconds(totalTicks) * 1.0e6 / static_cast(measuredBlocks)); + result->setProperty("p99Microseconds", percentile(durationHistogram, measuredBlocks, .99, maximumTicks)); result->setProperty("p999Microseconds", percentile(durationHistogram, measuredBlocks, .999, maximumTicks)); + result->setProperty("maximumMicroseconds", juce::Time::highResolutionTicksToSeconds(maximumTicks) * 1.0e6); result->setProperty("engineDeadlineMisses", deadlineMisses); + result->setProperty("arrivalP99Microseconds", percentile(arrivalHistogram, arrivalCount, .99, maximumArrival)); result->setProperty("arrivalMaximumMicroseconds", juce::Time::highResolutionTicksToSeconds(maximumArrival) * 1.0e6); + result->setProperty("percentileMethod", "All callbacks counted in 1/256-budget histogram bins; upper bin bound reported. Overflow at 4 budgets conservatively reports observed maximum."); + result->setProperty("durationHistogramOverflow", durationHistogram.back()); result->setProperty("arrivalHistogramOverflow", arrivalHistogram.back()); + result->setProperty("uniformTraceStride", traceStride); result->setProperty("uniformTraceRows", static_cast(count)); result->setProperty("deadlineTraceRows", static_cast(missedCount)); + result->setProperty("traceScope", "Bounded uniform sample plus first 4096 deadline misses; duplicate rows are intentional. Summary counts, extrema and histograms include every measured callback."); + result->setProperty("arrivalIntervalsAbove150Percent", lateArrivals); result->setProperty("arrivalStatus", "diagnostic_only: driver batching and scheduling both affect arrival intervals"); + result->setProperty("outputPeak", peak); result->setProperty("outputSamplesAtOrAboveFullScale", clipped); + result->setProperty("nonFiniteOutputSamples", nonFinite); result->setProperty("clippingStatus", "diagnostic_only: synthetic signal and unchanged preset gains"); + result->setProperty("capacityStatus", !valid ? "not_asserted" : deadlineMisses == 0 ? "pass" : "fail"); + result->setProperty("engineCapacityStatus", !valid ? "not_asserted" : deadlineMisses == 0 ? "pass" : "fail"); + result->setProperty("traceWritten", !csv.exists() && csv.replaceWithText(text)); + return juce::var(result); + } + std::atomic finished {false}, invalid {false}; +private: + size_t histogramBin(juce::int64 ticks) const noexcept + { return static_cast(juce::jlimit(0.0, 1024.0, static_cast(ticks) * 256.0 / budgetTicks)); } + double percentile(const std::array& histogram, juce::int64 observations, double fraction, juce::int64 maximum) const + { + if (observations == 0) return 0.0; + const auto rank = static_cast(std::ceil(static_cast(observations) * fraction)); + juce::int64 cumulative = 0; + for (size_t bin = 0; bin < histogram.size(); ++bin) + { + cumulative += histogram[bin]; + if (cumulative >= rank) return bin == histogram.size() - 1 + ? juce::Time::highResolutionTicksToSeconds(maximum) * 1.0e6 + : static_cast(bin + 1) * requestedBlock * 1.0e6 / (options.sampleRate * 256.0); + } + return 0.0; + } + AudioEngine& engine; + const Options options; + const int cycleFrames; + juce::AudioBuffer input, output; + std::vector records, missedRecords; + std::array durationHistogram {}, arrivalHistogram {}; + std::atomic enabled {false}; + size_t count = 0, missedCount = 0; + juce::int64 frames = 0, previousArrival = 0, nonFinite = 0, clipped = 0; + juce::int64 warmupFrames = 0, targetFrames = 0, measuredBlocks = 0, measuredFrames = 0, deadlineMisses = 0, lateArrivals = 0; + juce::int64 totalTicks = 0, maximumTicks = 0, maximumArrival = 0, arrivalCount = 0, traceStride = 1; + double budgetTicks = 1.0; + int requestedBlock = 0, starts = 0; + float peak = 0.0f; +}; + +inline juce::var verifyRecordings(AudioEngine& engine, const Callback& callback, const juce::StringArray& tracks, + const juce::File& directory, double sampleRate) +{ + const auto clips = engine.getLastCompletedClips(); + const auto failures = engine.takeRecordingWriteFailures(); + bool valid = clips.size() == static_cast(tracks.size()) && failures.isArray() && failures.getArray()->isEmpty(); + juce::StringArray seen; + juce::Array files; + juce::WavAudioFormat format; + juce::AudioBuffer audio(1, 65536); + for (const auto& clip : clips) + { + const int channel = tracks.indexOf(clip.trackId); + bool sane = channel >= 0 && !seen.contains(clip.trackId) && clip.file.isAChildOf(directory) && clip.file.existsAsFile(); + seen.add(clip.trackId); + std::unique_ptr reader(sane ? format.createReaderFor(clip.file.createInputStream().release(), true) : nullptr); + sane = sane && reader != nullptr && reader->sampleRate == sampleRate && reader->numChannels == 1 + && reader->lengthInSamples == callback.processedFrames() && reader->lengthInSamples > 0; + float peak = 0.0f, maximumDifference = 0.0f; + if (sane) + for (juce::int64 offset = 0; offset < reader->lengthInSamples; offset += audio.getNumSamples()) + { + const int count = static_cast(std::min(audio.getNumSamples(), reader->lengthInSamples - offset)); + sane = reader->read(&audio, 0, count, offset, true, false) && sane; + for (int sample = 0; sample < count; ++sample) + { + const float value = audio.getSample(0, sample); + sane = sane && std::isfinite(value); + if (std::isfinite(value)) + { + peak = juce::jmax(peak, std::abs(value)); + maximumDifference = juce::jmax(maximumDifference, std::abs(value - callback.inputAt(channel, offset + sample))); + } + } + } + const double tolerance = reader != nullptr && !reader->usesFloatingPointData + ? 2.0 * std::ldexp(1.0, 1 - static_cast(reader->bitsPerSample)) : 0.000002; + sane = sane && peak > 0.000001f && maximumDifference <= tolerance; + auto* row = new juce::DynamicObject(); row->setProperty("trackId", clip.trackId); row->setProperty("path", clip.file.getFullPathName()); + row->setProperty("status", sane ? "pass" : "fail"); row->setProperty("frames", reader != nullptr ? reader->lengthInSamples : 0); + row->setProperty("expectedFrames", callback.processedFrames()); row->setProperty("peak", peak); row->setProperty("maximumRawInputDifference", maximumDifference); + row->setProperty("bitsPerSample", reader != nullptr ? static_cast(reader->bitsPerSample) : 0); + row->setProperty("rawInputTolerance", tolerance); + files.add(row); valid = valid && sane; + } + auto* result = new juce::DynamicObject(); result->setProperty("status", valid ? "pass" : "fail"); + result->setProperty("files", files); result->setProperty("writeFailures", failures); + result->setProperty("scope", "Production raw recording tap and ThreadedWriter; every sample checked against the independent synthetic input, exact frame count, mono layout and rate. Tolerance is two PCM LSBs, or 2e-6 for floating-point recordings."); + return juce::var(result); +} + +inline int run(const juce::String& nameFragment, const juce::File& guitar, const juce::File& bass, + const juce::File& directory, const Options& options = {}) +{ + if (!validOptions(options) || nameFragment.isEmpty() || !guitar.existsAsFile() || !bass.existsAsFile() || directory.exists() || !directory.createDirectory()) return 2; + juce::Array checks, cases, presets; + bool passed = true; + const auto check = [&](const juce::String& id, bool success, const juce::var& detail) + { + auto* value = new juce::DynamicObject(); value->setProperty("id", id); + value->setProperty("status", success ? "pass" : "fail"); value->setProperty("detail", detail); + checks.add(value); passed = passed && success; + }; + const auto settings = AppPaths::applicationData().getChildFile("audio_device_settings.xml"); + const bool settingsExisted = settings.exists(); juce::MemoryBlock settingsBefore; + const bool settingsReadable = !settingsExisted || settings.loadFileAsData(settingsBefore); + check("settings_snapshot", settingsReadable, settings.getFullPathName()); + auto engine = std::make_unique(AudioEngine::DeviceStartup::offline); + const bool workersConfigured = engine->setRealtimeTrackWorkerCount(options.workerCount); + const bool spinConfigured = engine->setRealtimeTrackWorkerSpinMicroseconds(options.workerSpinMicroseconds); + const bool graceConfigured = engine->setProbeTrackRecoveryGraceMicroseconds(options.recoveryGraceMicroseconds); + check("worker_recovery_grace", graceConfigured, options.recoveryGraceMicroseconds); + check("worker_configuration", workersConfigured, engine->getRealtimeTrackExecutionDiagnostics()); + check("worker_spin_configuration", spinConfigured, options.workerSpinMicroseconds); + auto& manager = engine->getDeviceManager(); + manager.removeAudioCallback(engine.get()); // Defensive: the offline constructor currently registers no callback. + juce::StringArray matches; + for (auto* type : manager.getAvailableDeviceTypes()) + if (type->getTypeName() == "ASIO") + { + type->scanForDevices(); + for (const auto& name : type->getDeviceNames(false)) + if (name.containsIgnoreCase(nameFragment) && type->getDeviceNames(true).contains(name)) matches.addIfNotAlreadyThere(name); + } + const juce::String selected = matches.size() == 1 ? matches[0] : juce::String(); + check("unique_asio_device", selected.isNotEmpty(), matches.joinIntoString(", ")); + juce::DynamicObject::Ptr before = new juce::DynamicObject(), channels = new juce::DynamicObject(); + const bool queried = selected.isNotEmpty() && ASIOCapabilities::query(selected, *before, *channels); + const double originalRate = static_cast(before->getProperty("reportedCurrentSampleRate")); + const int originalBuffer = static_cast(before->getProperty("reportedPreferredBufferSize")); + const bool restorable = queried && static_cast(before->getProperty("currentSampleRateReadbackValid")) + && std::isfinite(originalRate) && originalRate > 0.0 && originalBuffer > 0; + check("inactive_driver_restorable", restorable, juce::var(before.get())); + const auto buffers = before->getProperty("bufferSizes"), rates = before->getProperty("sampleRates"); + bool supported = buffers.isArray() && rates.isArray() && static_cast(channels->getProperty("numInputChannels")) >= 2 + && static_cast(channels->getProperty("numOutputChannels")) >= 2; + for (int size : options.buffers) supported = supported && buffers.getArray()->contains(juce::var(size)); + supported = supported && rates.getArray()->contains(juce::var(options.sampleRate)); + check("requested_configuration_supported", supported, juce::var(channels.get())); + check("default_oversampling_is_four", engine->getNAMRackOversamplingFactor() == 4, "No settings-writing oversampling or device setter is called."); + std::vector states(static_cast(options.trackCount)); + juce::StringArray tracks; + for (int index = 0; index < options.trackCount; ++index) tracks.add("nam-probe-" + juce::String(index + 1) + (index % 2 == 0 ? "-guitar" : "-bass")); + const std::array files { guitar, bass }; + for (size_t index = 0; index < states.size(); ++index) + { + bool migrated = false; + const auto& presetFile = files[index % files.size()]; + bool valid = presetFile.loadFileAsData(states[index]) && OpenStudioNAMRack::migratePresetStateToCurrent(states[index], migrated); + auto tree = juce::ValueTree::readFromData(states[index].getData(), states[index].getSize()); + valid = valid && tree.hasType("OpenStudioNAMRack"); + if (index % 2 == 0 && valid) tree.setProperty("chaosEnabled", 1.0f, nullptr); + auto* resource = new juce::DynamicObject(); resource->setProperty("path", presetFile.getFullPathName()); + resource->setProperty("sha256", juce::SHA256(presetFile).toHexString()); resource->setProperty("migratedInMemory", migrated); + resource->setProperty("instrumentProfile", tree.getProperty("instrumentProfile")); + resource->setProperty("syntheticInputChannel", static_cast(index)); + resource->setProperty("syntheticFundamentalHz", (index % 2 == 0 ? 82.5 : 31.0) + 1.5 * static_cast(index / 2)); + valid = valid && tree.getProperty("ampModelPath").toString().isNotEmpty() && static_cast(tree.getProperty("ampEnabled", 1.0f)) >= 0.5f; + for (const auto* key : {"ampModelPath", "pedalModelPath", "cabIRPath"}) + { + const auto path = tree.getProperty(key).toString(); + if (path.isEmpty()) continue; + const juce::File file(path); valid = valid && file.existsAsFile(); + resource->setProperty(key, path); resource->setProperty(juce::String(key) + "Sha256", file.existsAsFile() ? juce::SHA256(file).toHexString() : juce::String()); + } + const auto xml = tree.createXml(); resource->setProperty("requestedState", xml != nullptr ? xml->toString() : juce::String()); + states[index].reset(); { juce::MemoryOutputStream stream(states[index], false); tree.writeToStream(stream); } + const auto id = tracks[static_cast(index)]; + valid = valid && engine->addTrack(id, "audio") == id && engine->addTrackBuiltInFX(id, "OpenStudio NAM Rack", false); + engine->setTrackInputChannels(id, static_cast(index), 1); engine->setTrackRecordArm(id, true); engine->setTrackInputMonitoring(id, true); + presets.add(resource); check("preset_" + juce::String(static_cast(index)), valid, juce::var(resource)); + } + bool restored = false, restoreAttempted = false, touched = false; + if (passed && settingsReadable && restorable && supported) + { + Callback callback(*engine, options); + const auto apply = [&](double sampleRate, int block) + { + juce::AudioDeviceManager::AudioDeviceSetup setup; + setup.inputDeviceName = setup.outputDeviceName = selected; setup.sampleRate = sampleRate; setup.bufferSize = block; + return AudioDeviceConfiguration::apply(manager, "ASIO", setup, false, true, [](auto& value) + { + value.inputChannels.setRange(0, 2, true); value.outputChannels.setRange(0, 2, true); + value.useDefaultInputChannels = value.useDefaultOutputChannels = false; + }); + }; + const auto restore = [&] + { + restoreAttempted = true; + manager.removeAudioCallback(&callback); + if (engine->isTransportRecording()) engine->finalizeInterruptedRecording(); + engine->setTransportPlaying(false); manager.closeAudioDevice(); + const auto error = apply(originalRate, originalBuffer); + auto* device = manager.getCurrentAudioDevice(); + restored = error.isEmpty() && device != nullptr && std::abs(device->getCurrentSampleRate() - originalRate) <= 1.0 + && device->getCurrentBufferSizeSamples() == originalBuffer; + check("restore_driver_configuration", restored, error); + manager.closeAudioDevice(); + }; + const juce::ScopeGuard cleanup {[&] { if (touched && !restoreAttempted) restore(); manager.removeAudioCallback(&callback); manager.closeAudioDevice(); }}; + try + { + for (int block : options.buffers) + { + bool ready = true; + juce::Array stateReadbacks; + for (size_t index = 0; index < states.size(); ++index) + { + const auto id = tracks[static_cast(index)]; + ready = engine->setPluginState(id, 0, false, states[index].toBase64Encoding()) && ready; + ready = engine->setProbeNAMRackOversamplingFactor(id, options.oversamplingFactor) && ready; + juce::MemoryBlock readback; ready = readback.fromBase64Encoding(engine->getPluginState(id, 0, false)) && ready; + const auto actual = juce::ValueTree::readFromData(readback.getData(), readback.getSize()); + const auto requested = juce::ValueTree::readFromData(states[index].getData(), states[index].getSize()); + for (const auto* key : {"ampModelPath", "pedalModelPath", "cabIRPath"}) + if (requested.hasProperty(key)) ready = ready && actual.hasProperty(key) + && actual.getProperty(key).toString() == requested.getProperty(key).toString(); + for (const auto* key : {"instrumentProfile", "ampEnabled", "cabEnabled", + "cabRoomEnabled", "cabDoublerEnabled", "precisionDriveEnabled", "chaosEnabled", "fuzzEnabled", "octaverEnabled", + "compressorEnabled", "preEqEnabled", "eqEnabled", "modulatorEnabled", "delayEnabled", "reverbEnabled"}) + if (requested.hasProperty(key)) + { + const double expected = static_cast(requested.getProperty(key)); + const double accepted = static_cast(actual.getProperty(key)); + ready = ready && actual.hasProperty(key) && std::isfinite(expected) + && std::isfinite(accepted) && accepted == expected; + } + ready = ready && static_cast(actual.getProperty("ampEnabled", 0.0f)) >= 0.5f; + const auto xml = actual.createXml(); stateReadbacks.add(xml != nullptr ? xml->toString() : juce::String()); + } + check("state_restore_" + juce::String(block), ready, stateReadbacks); + const auto recordingDirectory = directory.getChildFile("recordings-buffer-" + juce::String(block)); + if (options.recording) + { + const bool isolated = !recordingDirectory.exists() && recordingDirectory.createDirectory() + && engine->setProbeRecordingDirectory(recordingDirectory); + check("recording_directory_" + juce::String(block), isolated, recordingDirectory.getFullPathName()); + ready = ready && isolated; + } + if (!ready) break; + callback.begin(block); manager.addAudioCallback(&callback); touched = true; + const auto error = apply(options.sampleRate, block); + auto* device = manager.getCurrentAudioDevice(); + bool accepted = error.isEmpty() && device != nullptr && device->getCurrentSampleRate() == options.sampleRate && device->getCurrentBufferSizeSamples() == block; + bool recordingStarted = !options.recording; + if (accepted && options.recording) + { + engine->setTransportPosition(0.0); engine->setTransportRecording(true); engine->setTransportPlaying(true); + recordingStarted = engine->isTransportRecording() && engine->isTransportPlaying(); + check("recording_started_" + juce::String(block), recordingStarted, "Production armed-track recording with synthetic inputs; physical inputs are discarded."); + } + const auto workerBaseline = engine->getRealtimeTrackExecutionDiagnostics(); + if (accepted && recordingStarted) callback.start(); + const auto deadline = juce::Time::getMillisecondCounterHiRes() + (options.warmupSeconds + options.measurementSeconds + 15.0) * 1000.0; + while (accepted && recordingStarted && !callback.finished.load(std::memory_order_acquire) && juce::Time::getMillisecondCounterHiRes() < deadline) + juce::MessageManager::getInstance()->runDispatchLoopUntil(100); + // finished publishes the last engine return; subsequent proxy + // calls only clear output. Capture live worker/MMCSS counts + // before audioDeviceStopped retires the worker threads. + const auto workerState = engine->getRealtimeTrackExecutionDiagnostics(); + manager.removeAudioCallback(&callback); + // Preserve rolling-window telemetry before CSV formatting or + // other report work can age the stopped callback samples out. + const auto engineState = engine->getAudioDebugSnapshot(); + bool witnesses = true; juce::Array diagnostics; + for (const auto& id : tracks) + { + const auto state = engine->getNAMRackDiagnostics(id, "track", 0); diagnostics.add(state); + witnesses = witnesses && static_cast(state.getProperty("lastAmpDspFrames", 0)) > 0 + && static_cast(state.getProperty("embeddedDriveRequestedOversamplingFactor", 0)) == options.oversamplingFactor + && static_cast(state.getProperty("embeddedDriveActiveOversamplingFactor", 0)) == options.oversamplingFactor + && static_cast(state.getProperty("routedInputChannelCount", 0)) == 1 + && static_cast(state.getProperty("lastRawInputPeakDb", -120.0)) > -80.0 + && static_cast(state.getProperty("maximumOutputPeakLinear", 0.0)) > 0.000001 + && !static_cast(state.getProperty("ampModelFaulted", true)) && !static_cast(state.getProperty("realtimeDSPBlocked", true)); + for (const auto* key : {"modelProcessFailCount", "audioThreadResizeAvoidedCount", "oversizeBypassCount", "modelSnapshotLockMissCount", "realtimeSafetyBypassCount", "rackOutputEmergencyMuteTripCount", "inputNonFiniteSampleCount"}) + witnesses = witnesses && static_cast(state.getProperty(key, -1)) == 0; + } + device = manager.getCurrentAudioDevice(); + accepted = accepted && device != nullptr && device->getCurrentSampleRate() == options.sampleRate && device->getCurrentBufferSizeSamples() == block; + const int xruns = device != nullptr ? device->getXRunCount() : -1; + juce::var recordingResult; + if (options.recording) + { + if (!engine->finalizeInterruptedRecording()) { engine->setTransportRecording(false); engine->setTransportPlaying(false); } + recordingResult = verifyRecordings(*engine, callback, tracks, recordingDirectory, options.sampleRate); + recordingStarted = recordingStarted && recordingResult.getProperty("status", {}).toString() == "pass"; + } + auto row = callback.report(directory.getChildFile("buffer-" + juce::String(block) + ".csv")); + auto* detail = row.getDynamicObject(); detail->setProperty("openError", error); detail->setProperty("settingsAccepted", accepted); + detail->setProperty("callbackDurationCapacityStatus", row.getProperty("engineCapacityStatus", "not_asserted")); + detail->setProperty("driverXRuns", xruns); + detail->setProperty("deviceCapacityStatus", !accepted || xruns < 0 ? "not_asserted" : xruns == 0 ? "pass" : "fail"); + detail->setProperty("deviceCapacityScope", "Driver-reported xruns over warmup and measurement; unsupported readback is not_asserted. Separate from engine processing duration."); + detail->setProperty("engineDiagnostics", engineState); + detail->setProperty("workerDiagnostics", workerState); detail->setProperty("workerBaseline", workerBaseline); + auto* workerDelta = new juce::DynamicObject(); bool workerCountersValid = true, workerCapacity = true; + for (const auto* key : {"deadlineMisses", "rejectedTrackJobs", "quarantinedBlocks", "cancelledJobs", "rejectedBatches", + "batches", "completedBatches", "completedLateBatches", "hardDeadlineMisses", + "callbackJobs", "workerJobs", "parallelTrackBlocks"}) + { + const auto initial = static_cast(workerBaseline.getProperty(key, -1)); + const auto final = static_cast(workerState.getProperty(key, -1)); + const auto delta = final - initial; + workerCountersValid = workerCountersValid && initial >= 0 && final >= initial; + workerDelta->setProperty(key, delta); + } + for (const auto* key : {"deadlineMisses", "rejectedTrackJobs", "quarantinedBlocks", "cancelledJobs", "rejectedBatches"}) + workerCapacity = workerCapacity && static_cast(workerDelta->getProperty(key)) == 0; + detail->setProperty("workerCaseDeltas", juce::var(workerDelta)); + detail->setProperty("workerCapacityStatus", !workerCountersValid ? "not_asserted" : workerCapacity ? "pass" : "fail"); + detail->setProperty("workerCapacityScope", "Per-case deltas over warmup and measurement. Cancelled/rejected/quarantined work fails capacity even when the outer callback returns within its deadline."); + detail->setProperty("recording", options.recording ? recordingResult : juce::var("not_requested")); + detail->setProperty("rackDiagnostics", diagnostics); detail->setProperty("stateReadbacks", stateReadbacks); + const bool mmcss = static_cast(engineState.getProperty("windowsAudioCallbackMMCSSActive", false)) + && static_cast(engineState.getProperty("windowsAudioCallbackMMCSSPriorityApplied", false)); + const int expectedWorkers = options.workerCount < 0 + ? RealtimeTrackExecutor::recommendedWorkerCount() : options.workerCount; + const bool workersReady = static_cast(workerState.getProperty("workers", -1)) == expectedWorkers + && static_cast(workerState.getProperty("mmcssRegisteredWorkers", -1)) == expectedWorkers + && static_cast(workerState.getProperty("mmcssPriorityWorkers", -1)) == expectedWorkers; + detail->setProperty("workerConfigurationVerified", workersReady); + const bool valid = accepted && witnesses && mmcss && workersReady && recordingStarted && workerCountersValid && static_cast(row.getProperty("processingValid", false)) && static_cast(row.getProperty("traceWritten", false)); + detail->setProperty("processingIntegrity", valid ? "pass" : "fail"); + if (!valid) { detail->setProperty("capacityStatus", "not_asserted"); detail->setProperty("engineCapacityStatus", "not_asserted"); } + // An observed executor overrun remains a capacity failure even + // when dropped work also prevents an output/execution witness. + if (workerCountersValid && !workerCapacity) { detail->setProperty("capacityStatus", "fail"); detail->setProperty("engineCapacityStatus", "fail"); } + if (accepted && xruns > 0) detail->setProperty("capacityStatus", "fail"); + detail->setProperty("engineCapacityScope", "Callback-duration deadlines and worker completion; cancelled/rejected/quarantined DSP is a capacity failure. Driver telemetry remains separate."); + check("loaded_callback_" + juce::String(block), valid, "Driver settings, finite nonzero synthetic output, loaded NAM execution, " + + juce::String(options.oversamplingFactor) + "x oversampling, MMCSS and callback safety counters."); + cases.add(row); manager.closeAudioDevice(); + if (!accepted) break; + } + } + catch (const std::exception& error) { check("probe_exception", false, juce::String(error.what())); } + catch (...) { check("probe_exception", false, "Unknown exception"); } + if (touched) restore(); + } + check("global_oversampling_preference_unchanged", engine->getNAMRackOversamplingFactor() == 4, + "Per-rack probe requests never call the settings-writing global oversampling setter."); + engine.reset(); + if (touched) + { + juce::DynamicObject::Ptr after = new juce::DynamicObject(), afterChannels = new juce::DynamicObject(); + const bool read = ASIOCapabilities::query(selected, *after, *afterChannels); + check("inactive_driver_restored", restored && read && static_cast(after->getProperty("currentSampleRateReadbackValid")) + && std::abs(static_cast(after->getProperty("reportedCurrentSampleRate")) - originalRate) <= 1.0 + && static_cast(after->getProperty("reportedPreferredBufferSize")) == originalBuffer, juce::var(after.get())); + } + juce::MemoryBlock settingsAfter; + check("saved_settings_byte_identical", settingsReadable && settingsExisted == settings.exists() + && (!settingsExisted || (settings.loadFileAsData(settingsAfter) && settingsBefore == settingsAfter)), settings.getFullPathName()); + check("requested_cases_completed", cases.size() == static_cast(options.buffers.size()), cases.size()); + auto* result = new juce::DynamicObject(); result->setProperty("success", passed); result->setProperty("checks", checks); result->setProperty("cases", cases); + result->setProperty("presets", presets); result->setProperty("driver", selected); result->setProperty("sampleRate", options.sampleRate); result->setProperty("oversampling", options.oversamplingFactor); + result->setProperty("warmupSeconds", options.warmupSeconds); result->setProperty("measurementSeconds", options.measurementSeconds); + result->setProperty("trackCount", options.trackCount); result->setProperty("requestedWorkerCount", options.workerCount); result->setProperty("recordingRequested", options.recording); + result->setProperty("requestedWorkerSpinMicroseconds", options.workerSpinMicroseconds); + result->setProperty("requestedRecoveryGraceMicroseconds", options.recoveryGraceMicroseconds); + result->setProperty("inputFixtureVersion", 2); result->setProperty("inputFixturePeriodSeconds", 2); + juce::Array requestedBuffers; for (int buffer : options.buffers) requestedBuffers.add(buffer); result->setProperty("requestedBuffers", requestedBuffers); + #if JUCE_DEBUG + result->setProperty("buildConfiguration", "Debug"); + #else + result->setProperty("buildConfiguration", "Release"); + #endif + const auto executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + result->setProperty("executable", executable.getFullPathName()); result->setProperty("executableSha256", juce::SHA256(executable).toHexString()); + result->setProperty("inputPolicy", "Physical input pointers are never read or recorded; independent periodic guitar/bass mono fixtures feed unique synthetic channels. Physical I/O stays at two channels, and physical outputs are always silent."); + result->setProperty("timingScope", "Wall time of the full AudioEngine callback on the real ASIO callback thread, including engine telemetry. Excludes proxy preparation/inspection and surrounding JUCE/ASIO wrapper work. No WebView is loaded."); + result->setProperty("audioQuality", "not_asserted: silent synthetic hardware qualification cannot establish audible live crackle, tone or instrument routing"); + result->setProperty("restorationScope", "Inactive ASIO observed rate/preferred buffer restored; other audio clients must be closed. User settings and source presets are never saved."); + const bool written = directory.getChildFile("result.json").replaceWithText(juce::JSON::toString(juce::var(result), true)); + return passed && written ? 0 : 1; +} +} diff --git a/Source/NAMNonlinearAliasAnalysis.h b/Source/NAMNonlinearAliasAnalysis.h new file mode 100644 index 0000000..7e26741 --- /dev/null +++ b/Source/NAMNonlinearAliasAnalysis.h @@ -0,0 +1,214 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Calling-thread QA math shared by the native Drive and opt-in Fuzz probes. +// This is never called by the audio renderer. It removes only reported integer +// latency and a bounded fractional delay; it does not fit output gain or EQ. +class NAMNonlinearAliasAnalysis final +{ +public: + static constexpr int decimatorTapCount = 513; + static constexpr int decimatorHalfLength = decimatorTapCount / 2; + static constexpr double analysisPeriodSeconds = 0.010; + + explicit NAMNonlinearAliasAnalysis(int referenceRateFactor = 4) + : rateFactor(juce::jmax(1, referenceRateFactor)), + decimatorCoefficients(static_cast(decimatorTapCount), 0.0) + { + const double normalizedCutoff = 0.45 / static_cast(rateFactor); + double coefficientSum = 0.0; + for (int tap = 0; tap < decimatorTapCount; ++tap) + { + const int offset = tap - decimatorHalfLength; + const double sincArgument = + 2.0 * normalizedCutoff * static_cast(offset); + const double sinc = offset == 0 + ? 1.0 + : std::sin(juce::MathConstants::pi * sincArgument) + / (juce::MathConstants::pi * sincArgument); + const double phase = static_cast(tap) + / static_cast(decimatorTapCount - 1); + const double window = 0.35875 + - 0.48829 * std::cos(juce::MathConstants::twoPi * phase) + + 0.14128 * std::cos(2.0 * juce::MathConstants::twoPi * phase) + - 0.01168 * std::cos(3.0 * juce::MathConstants::twoPi * phase); + const double coefficient = 2.0 * normalizedCutoff * sinc * window; + decimatorCoefficients[static_cast(tap)] = coefficient; + coefficientSum += coefficient; + } + for (auto& coefficient : decimatorCoefficients) + coefficient /= coefficientSum; + } + + std::vector decimate(const std::vector& highRate) const + { + std::vector result; + if (highRate.size() <= decimatorCoefficients.size()) + return result; + const int lastCentre = static_cast(highRate.size()) + - decimatorHalfLength - 1; + const int outputCount = 1 + + (lastCentre - decimatorHalfLength) / rateFactor; + result.reserve(static_cast(juce::jmax(0, outputCount))); + for (int centre = decimatorHalfLength; + centre <= lastCentre; centre += rateFactor) + { + double sum = 0.0; + const int first = centre - decimatorHalfLength; + for (int tap = 0; tap < static_cast(decimatorCoefficients.size()); ++tap) + sum += decimatorCoefficients[static_cast(tap)] + * static_cast(highRate[static_cast(first + tap)]); + result.push_back(static_cast(sum)); + } + return result; + } + + struct AlignedResidual + { + double unoccupiedRatio = 1.0; + double occupiedRatio = 1.0; + double fractionalAdvance = 0.0; + int integerAdvance = 0; + int unoccupiedBins = 0; + int occupiedBins = 0; + bool finite = false; + }; + + static AlignedResidual measureAlignedResidual( + const std::vector& device, const std::vector& reference, + double sampleRate, double expectedAdvance, int occupiedStride) + { + AlignedResidual result; + const int available = juce::jmin( + static_cast(device.size()), static_cast(reference.size())); + const int periodSamples = juce::jmax(32, + static_cast(std::round(sampleRate * analysisPeriodSeconds))); + result.integerAdvance = static_cast(std::round(expectedAdvance)); + const int margin = std::abs(result.integerAdvance) + 8; + const int referenceStart = available - margin - periodSamples; + const int deviceStart = referenceStart + result.integerAdvance; + if (referenceStart < 0 || deviceStart < 0 + || referenceStart + periodSamples > available + || deviceStart + periodSamples > available) + return result; + + std::vector devicePeriod(static_cast(periodSamples)); + std::vector referencePeriod(static_cast(periodSamples)); + for (int sample = 0; sample < periodSamples; ++sample) + { + devicePeriod[static_cast(sample)] = + device[static_cast(deviceStart + sample)]; + referencePeriod[static_cast(sample)] = + reference[static_cast(referenceStart + sample)]; + } + const auto deviceSpectrum = makeSpectrum(devicePeriod); + const auto referenceSpectrum = makeSpectrum(referencePeriod); + double maximumReferenceMagnitude = 0.0; + for (size_t bin = 1; bin < referenceSpectrum.size(); ++bin) + { + if (static_cast(bin) % occupiedStride != 0) + continue; + maximumReferenceMagnitude = juce::jmax( + maximumReferenceMagnitude, std::abs(referenceSpectrum[bin])); + } + const double occupiedFloor = juce::jmax( + 1.0e-14, maximumReferenceMagnitude * 1.0e-5); + + double bestError = std::numeric_limits::max(); + for (int stepIndex = -128; stepIndex <= 128; ++stepIndex) + { + const double fractional = static_cast(stepIndex) / 256.0; + double error = 0.0; + int bins = 0; + for (size_t bin = 1; bin < referenceSpectrum.size(); ++bin) + { + const double frequency = static_cast(bin) + * sampleRate / static_cast(periodSamples); + if (frequency < 20.0 || frequency > sampleRate * 0.45 + || static_cast(bin) % occupiedStride != 0 + || std::abs(referenceSpectrum[bin]) < occupiedFloor) + continue; + const double phase = juce::MathConstants::twoPi + * static_cast(bin) * fractional + / static_cast(periodSamples); + const std::complex rotated = deviceSpectrum[bin] + * std::complex(std::cos(phase), std::sin(phase)); + error += std::norm(rotated - referenceSpectrum[bin]); + ++bins; + } + if (bins > 0 && error < bestError) + { + bestError = error; + result.fractionalAdvance = fractional; + } + } + + double referencePower = 0.0; + double occupiedResidualPower = 0.0; + double unoccupiedResidualPower = 0.0; + for (size_t bin = 1; bin < referenceSpectrum.size(); ++bin) + { + const double frequency = static_cast(bin) + * sampleRate / static_cast(periodSamples); + if (frequency < 20.0 || frequency > sampleRate * 0.45) + continue; + const double phase = juce::MathConstants::twoPi + * static_cast(bin) * result.fractionalAdvance + / static_cast(periodSamples); + const std::complex rotated = deviceSpectrum[bin] + * std::complex(std::cos(phase), std::sin(phase)); + const double residual = std::norm(rotated - referenceSpectrum[bin]); + const bool analyticallyOccupied = static_cast(bin) % occupiedStride == 0; + if (analyticallyOccupied) + { + referencePower += std::norm(referenceSpectrum[bin]); + occupiedResidualPower += residual; + ++result.occupiedBins; + } + else + { + unoccupiedResidualPower += residual; + ++result.unoccupiedBins; + } + } + result.unoccupiedRatio = std::sqrt(unoccupiedResidualPower + / juce::jmax(1.0e-30, referencePower)); + result.occupiedRatio = std::sqrt(occupiedResidualPower + / juce::jmax(1.0e-30, referencePower)); + result.finite = result.unoccupiedBins > 0 && result.occupiedBins > 0 + && std::isfinite(result.unoccupiedRatio) + && std::isfinite(result.occupiedRatio) + && std::isfinite(result.fractionalAdvance); + return result; + } + +private: + static std::vector> makeSpectrum(const std::vector& period) + { + const int count = static_cast(period.size()); + std::vector> spectrum(static_cast(count / 2 + 1)); + for (int bin = 0; bin <= count / 2; ++bin) + { + const double angle = -juce::MathConstants::twoPi + * static_cast(bin) / static_cast(count); + const std::complex step(std::cos(angle), std::sin(angle)); + std::complex oscillator(1.0, 0.0); + std::complex sum(0.0, 0.0); + for (const float sample : period) + { + sum += static_cast(sample) * oscillator; + oscillator *= step; + } + spectrum[static_cast(bin)] = sum; + } + return spectrum; + } + + const int rateFactor; + std::vector decimatorCoefficients; +}; diff --git a/Source/NAMPolyOctaver.cpp b/Source/NAMPolyOctaver.cpp index 84d2a3a..11c5eee 100644 --- a/Source/NAMPolyOctaver.cpp +++ b/Source/NAMPolyOctaver.cpp @@ -38,11 +38,13 @@ */ #include "NAMPolyOctaver.h" +#include "NAMFuzz.h" #include #include #include #include +#include #include namespace @@ -529,6 +531,16 @@ void NAMPolyOctaver::resetDspState() noexcept dspStateIsReset = true; } +void NAMPolyOctaver::retireWetPath() noexcept +{ + if (! dspStateIsReset) + resetDspState(); + // No wet history remains audible. Off-edited profiles must be ready when + // a voice returns, rather than retaining an indefinitely paused morph. + for (auto& profile : smoothedBassProfile) + profile.setCurrentAndTargetValue(profile.getTargetValue()); +} + void NAMPolyOctaver::reset() noexcept { resetDspState(); @@ -768,6 +780,21 @@ NAMPolyOctaver::VoiceFrame NAMPolyOctaver::processVoiceSample( return output; auto& rateState = channelRateStates[static_cast(channel)]; + // Retire each reconstruction path on its first muted host frame, not at + // the next six-frame boundary or callback. Keep the shared analysis bank + // and resampling clock alive for the other voice. + if (! generateOctaveDown && rateState.downInterpolatorActive) + { + rateState.downInterpolator.reset(); + rateState.pendingDownOutput.fill(0.0f); + rateState.downInterpolatorActive = false; + } + if (! generateOctaveUp && rateState.upInterpolatorActive) + { + rateState.upInterpolator.reset(); + rateState.pendingUpOutput.fill(0.0f); + rateState.upInterpolatorActive = false; + } if (! std::isfinite(input)) { input = 0.0f; @@ -829,10 +856,7 @@ NAMPolyOctaver::VoiceFrame NAMPolyOctaver::processVoiceSample( } else { - if (rateState.downInterpolatorActive) - rateState.downInterpolator.reset(); rateState.pendingDownOutput.fill(0.0f); - rateState.downInterpolatorActive = false; } if (generateOctaveUp) @@ -844,10 +868,7 @@ NAMPolyOctaver::VoiceFrame NAMPolyOctaver::processVoiceSample( } else { - if (rateState.upInterpolatorActive) - rateState.upInterpolator.reset(); rateState.pendingUpOutput.fill(0.0f); - rateState.upInterpolatorActive = false; } rateState.phase = 0; } @@ -868,25 +889,37 @@ NAMPolyOctaver::VoiceFrame NAMPolyOctaver::processVoiceSample( return output; } -void NAMPolyOctaver::processBlock(juce::AudioBuffer& buffer) noexcept +void NAMPolyOctaver::processBlock(juce::AudioBuffer& buffer, + float* excitationGains) noexcept { juce::ScopedNoDenormals noDenormals; const int numSamples = buffer.getNumSamples(); const int numChannels = buffer.getNumChannels(); + const auto fillUnityExcitation = [excitationGains, numSamples] () noexcept + { + if (excitationGains != nullptr && numSamples > 0) + std::fill_n(excitationGains, numSamples, 1.0f); + }; if (numSamples <= 0 || numChannels <= 0) + { + fillUnityExcitation(); return; + } if (! prepared.load(std::memory_order_acquire)) + { + fillUnityExcitation(); return; + } synchroniseLevelTargets(); synchroniseProfileTargets(); if (wetPathIsExactlySilent() && directPathIsExactlyUnity()) { - if (! dspStateIsReset) - resetDspState(); + fillUnityExcitation(); + retireWetPath(); diagnosticProcessedBlocks.fetch_add(1, std::memory_order_relaxed); diagnosticProcessedSamples.fetch_add( static_cast(numSamples), @@ -899,8 +932,8 @@ void NAMPolyOctaver::processBlock(juce::AudioBuffer& buffer) noexcept if (allPathsAreExactlySilent()) { - if (! dspStateIsReset) - resetDspState(); + fillUnityExcitation(); + retireWetPath(); buffer.clear(); diagnosticProcessedBlocks.fetch_add(1, std::memory_order_relaxed); diagnosticProcessedSamples.fetch_add( @@ -914,8 +947,8 @@ void NAMPolyOctaver::processBlock(juce::AudioBuffer& buffer) noexcept if (wetPathIsExactlySilent()) { - if (! dspStateIsReset) - resetDspState(); + fillUnityExcitation(); + retireWetPath(); for (int sample = 0; sample < numSamples; ++sample) { const float directLevel = smoothedDirectLevel.getNextValue(); @@ -933,14 +966,6 @@ void NAMPolyOctaver::processBlock(juce::AudioBuffer& buffer) noexcept } 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 channelData {}; for (int channel = 0; channel < processedChannels; ++channel) channelData[static_cast(channel)] = @@ -954,6 +979,29 @@ void NAMPolyOctaver::processBlock(juce::AudioBuffer& buffer) noexcept const float directLevel = smoothedDirectLevel.getNextValue(); const float downLevel = smoothedOctaveDownLevel.getNextValue(); const float upLevel = smoothedOctaveUpLevel.getNextValue(); + // A level can finish its fade inside this callback. Re-evaluate on the + // exact frame so short and long callbacks retain identical histories. + const bool generateOctaveDown = downLevel != 0.0f + || smoothedOctaveDownLevel.getTargetValue() != 0.0f; + const bool generateOctaveUp = upLevel != 0.0f + || smoothedOctaveUpLevel.getTargetValue() != 0.0f; + if (excitationGains != nullptr) + { + const float reference = juce::jmax(1.0f, directLevel); + excitationGains[sample] = reference / juce::jmax( + reference, directLevel + downLevel + upLevel); + } + + if (! generateOctaveDown && ! generateOctaveUp) + { + retireWetPath(); + for (int channel = 0; channel < numChannels; ++channel) + { + auto& value = buffer.getWritePointer(channel)[sample]; + value = directLevel == 0.0f ? 0.0f : value * directLevel; + } + continue; + } for (int channel = 0; channel < processedChannels; ++channel) { @@ -1104,6 +1152,214 @@ NAMPolyOctaver::Diagnostics NAMPolyOctaver::getDiagnostics() const noexcept return result; } +NAMPolyOctaver::LifecycleTestResult NAMPolyOctaver::runLifecycleSelfTest( + double sampleRate) +{ + LifecycleTestResult result; + const double rate = std::isfinite(sampleRate) && sampleRate > 1000.0 + ? sampleRate : 48000.0; + const int rampSamples = juce::jmax(1, static_cast(std::floor( + rate * static_cast(levelRampSeconds)))); + using Levels = std::array; + const Levels active { 1.0f, 0.7f, 0.4f }; + const Levels bypass { 1.0f, 0.0f, 0.0f }; + struct Event + { + int sample; + Levels levels; + int profile = -1; + bool changeLevels = true; + }; + struct Rendered + { + std::array, maximumChannels> audio; + std::vector excitation; + }; + const auto render = [&] (int count, int channels, int partition, + Levels initial, int profile, + const std::vector& events, int fuzzVoice = -1) + { + Rendered output; + output.excitation.resize(static_cast(count)); + for (int channel = 0; channel < channels; ++channel) + { + auto& samples = output.audio[static_cast(channel)]; + samples.resize(static_cast(count)); + for (int sample = 0; sample < count; ++sample) + { + const double time = sample / rate; + samples[static_cast(sample)] = static_cast( + 0.18 * std::sin(juce::MathConstants::twoPi + * (channel == 0 ? 41.2034 : 110.0) * time) + + 0.07 * std::sin(juce::MathConstants::twoPi + * (channel == 0 ? 901.0 : 1301.0) * time + 0.3)); + } + } + auto processor = std::make_unique(); + processor->setLevels(initial[0], initial[1], initial[2]); + processor->setInstrumentProfile(profile); + processor->prepare(rate, 2048); + NAMFuzz fuzz; + NAMFuzz::Parameters fuzzParameters; + fuzzParameters.enabled = true; + fuzzParameters.voice = juce::jmax(0, fuzzVoice); + fuzzParameters.bass = profile == 1; + fuzzParameters.drive = 0.73f; + fuzzParameters.tone = 0.31f; + fuzzParameters.levelDb = -4.25f; + fuzzParameters.cleanMix = 0.27f; + fuzzParameters.calibrationReferenceDbu = 18.0f; + fuzz.prepare(rate, fuzzParameters); + std::size_t eventIndex = 0; + for (int start = 0; start < count;) + { + while (eventIndex < events.size() && events[eventIndex].sample == start) + { + const auto& event = events[eventIndex++]; + if (event.changeLevels) + processor->setLevels(event.levels[0], event.levels[1], event.levels[2]); + if (event.profile >= 0) + { + processor->setInstrumentProfile(event.profile); + fuzzParameters.bass = event.profile == 1; + fuzz.setParameters(fuzzParameters); + } + } + int blockSize = juce::jmin(partition, count - start); + if (eventIndex < events.size()) + blockSize = juce::jmin(blockSize, events[eventIndex].sample - start); + std::array pointers {}; + for (int channel = 0; channel < channels; ++channel) + pointers[static_cast(channel)] = + output.audio[static_cast(channel)].data() + start; + juce::AudioBuffer block(pointers.data(), channels, blockSize); + auto* excitation = output.excitation.data() + start; + processor->processBlock(block, excitation); + if (fuzzVoice >= 0) + fuzz.processBlock(block, excitation); + start += blockSize; + } + return output; + }; + const auto audioDifference = [] (const Rendered& left, const Rendered& right, + int channels, int begin, int end) + { + float maximum = 0.0f; + for (int channel = 0; channel < channels; ++channel) + for (int sample = begin; sample < end; ++sample) + { + const auto ch = static_cast(channel); + const auto index = static_cast(sample); + const float a = left.audio[ch][index], b = right.audio[ch][index]; + if (! std::isfinite(a) || ! std::isfinite(b)) + return std::numeric_limits::infinity(); + maximum = juce::jmax(maximum, std::abs(a - b)); + } + return maximum; + }; + const auto mutedLevels = [] (int voice) + { + return Levels { 1.0f, voice == 2 ? 0.7f : 0.0f, + voice == 1 ? 0.4f : 0.0f }; + }; + // Include each phase of the six-frame resampler and very short off periods: + // restarting before the next reduced-rate tick must also discard that + // voice's reconstruction history without disturbing the continuing voice. + for (int channels : { 1, 2 }) + for (int profile : { 0, 1 }) + for (int voice = 0; voice < 3; ++voice) + for (int phase = 0; phase < resampleFactor; ++phase) + for (int dwell : { 0, 1, 5, 6, 7, 19, 31 }) + { + const int fadeAt = 257 + phase; + const int resumeAt = fadeAt + rampSamples + dwell; + const int count = resumeAt + rampSamples + 83; + const auto muted = mutedLevels(voice); + const std::vector events { { fadeAt, muted }, { resumeAt, active } }; + const auto reference = render(count, channels, 1, active, profile, events); + const auto remaining = render(count, channels, 2048, muted, profile, {}); + result.maximumUnaffectedVoiceDifference = juce::jmax( + result.maximumUnaffectedVoiceDifference, + audioDifference(reference, remaining, channels, + fadeAt + rampSamples - 1, resumeAt)); + for (int partition : { 31, 2048 }) + { + const auto actual = render(count, channels, partition, active, profile, events); + result.maximumRetirementDifference = juce::jmax( + result.maximumRetirementDifference, + audioDifference(reference, actual, channels, 0, count)); + result.maximumMetadataDifference = juce::jmax(result.maximumMetadataDifference, + maximumAbsoluteDifference(reference.excitation, actual.excitation)); + ++result.retirementComparisons; + } + } + for (int channels : { 1, 2 }) + for (int profile : { 0, 1 }) + { + const int activateAt = 2 * rampSamples + 31; + const int count = activateAt + rampSamples + 83; + for (float direct : { 0.0f, 0.5f, 1.0f, 1.25f }) + { + const Levels dry { direct, 0.0f, 0.0f }; + const auto fresh = render(count, channels, 1, dry, profile, + { { activateAt, active } }); + for (int partition : { 1, 31, 2048 }) + { + const auto edited = render(count, channels, partition, dry, 1 - profile, + { { 17, dry, profile, false }, { activateAt, active } }); + result.maximumRetiredProfileDifference = juce::jmax( + result.maximumRetiredProfileDifference, + audioDifference(fresh, edited, channels, 0, count)); + ++result.profileComparisons; + } + } + // A profile morph near retirement used to advance differently through + // the zero-wet callback suffix, surviving even a long bypass interval. + const int fadeAt = 257; + const int resumeAt = fadeAt + rampSamples + 2048; + const int end = resumeAt + rampSamples + 83; + const std::vector events { { fadeAt, bypass }, + { fadeAt + rampSamples - 17, bypass, 1 - profile, false }, + { resumeAt, active } }; + const auto reference = render(end, channels, 1, active, profile, events); + for (int partition : { 31, 2048 }) + { + const auto actual = render(end, channels, partition, active, profile, events); + result.maximumRetiredProfileDifference = juce::jmax( + result.maximumRetiredProfileDifference, + audioDifference(reference, actual, channels, 0, end)); + ++result.profileComparisons; + } + for (int voice = 0; voice < 3; ++voice) + for (int fuzzVoice = 0; fuzzVoice < 3; ++fuzzVoice) + { + const int pairResume = fadeAt + rampSamples + 19; + const int pairEnd = pairResume + rampSamples + 83; + const std::vector pairEvents { + { fadeAt, mutedLevels(voice) }, { pairResume, active } }; + const auto pairReference = render(pairEnd, channels, 1, + active, profile, pairEvents, fuzzVoice); + for (int partition : { 31, 2048 }) + { + const auto actual = render(pairEnd, channels, partition, + active, profile, pairEvents, fuzzVoice); + result.maximumFuzzPairDifference = juce::jmax( + result.maximumFuzzPairDifference, + audioDifference(pairReference, actual, channels, 0, pairEnd)); + ++result.fuzzPairComparisons; + } + } + } + result.retirementPartitionPassed = result.maximumRetirementDifference == 0.0f + && result.maximumMetadataDifference == 0.0f; + result.unaffectedVoicePassed = result.maximumUnaffectedVoiceDifference == 0.0f; + result.retiredProfileRecallPassed = result.maximumRetiredProfileDifference == 0.0f; + result.fuzzPairPartitionPassed = result.maximumFuzzPairDifference == 0.0f; + result.passed = result.retirementPartitionPassed && result.unaffectedVoicePassed + && result.retiredProfileRecallPassed && result.fuzzPairPartitionPassed; + return result; +} + void NAMPolyOctaver::resetDiagnostics() noexcept { diagnosticProcessedBlocks.store(0, std::memory_order_relaxed); @@ -1123,6 +1379,161 @@ NAMPolyOctaver::SelfTestResult NAMPolyOctaver::runDeterministicSelfTest( : 48000.0; static constexpr double twoPi = 2.0 * juce::MathConstants::pi; + { + // Metadata must describe the exact audio frame's ramped coefficients, + // while requesting it must never change the octave audio itself. + const int rampCount = juce::jmax(1, static_cast(std::floor( + safeSampleRate * static_cast(levelRampSeconds)))); + const int sampleCount = rampCount * 5 + 67; + const std::array changes { 0, rampCount * 2 + 17, + rampCount * 3 + 43 }; + const std::array, 3> levels {{ + { 1.25f, 1.0f, 1.0f }, { 0.5f, 0.25f, 0.75f }, + { 1.0f, 0.0f, 0.0f }, + }}; + bool coefficientBudgetPassed = true; + const auto render = [&] ( + const std::vector& partitions, bool withMetadata, + std::vector& left, std::vector& right, + std::vector& gains) + { + const auto processor = std::make_unique(); + processor->prepare(safeSampleRate, 257); + left.resize(static_cast(sampleCount)); + right.resize(static_cast(sampleCount)); + gains.assign(static_cast(sampleCount), -1.0f); + for (int sample = 0; sample < sampleCount; ++sample) + { + const double time = static_cast(sample) / safeSampleRate; + left[static_cast(sample)] = static_cast( + 0.18 * std::sin(twoPi * 110.0 * time)); + right[static_cast(sample)] = static_cast( + 0.14 * std::sin(twoPi * 173.0 * time + 0.2)); + } + std::array, 3> expectedLevels; + for (std::size_t index = 0; index < expectedLevels.size(); ++index) + { + expectedLevels[index].reset(safeSampleRate, levelRampSeconds); + expectedLevels[index].setCurrentAndTargetValue(index == 0 ? 1.0f : 0.0f); + } + int start = 0; + std::size_t partition = 0, change = 0; + while (start < sampleCount) + { + if (change < changes.size() && start == changes[change]) + { + const auto& target = levels[change]; + processor->setLevels(target[0], target[1], target[2]); + for (std::size_t index = 0; index < expectedLevels.size(); ++index) + expectedLevels[index].setTargetValue(target[index]); + ++change; + } + int count = juce::jmin(partitions[partition % partitions.size()], sampleCount - start); + if (change < changes.size()) count = juce::jmin(count, changes[change] - start); + std::array channels { left.data() + start, right.data() + start }; + juce::AudioBuffer block(channels.data(), 2, count); + processor->processBlock(block, withMetadata ? gains.data() + start : nullptr); + for (int sample = 0; sample < count; ++sample) + { + const float direct = expectedLevels[0].getNextValue(); + const float down = expectedLevels[1].getNextValue(); + const float up = expectedLevels[2].getNextValue(); + if (! withMetadata) continue; + const float gain = gains[static_cast(start + sample)]; + const double reference = juce::jmax(1.0, static_cast(direct)); + const double sum = static_cast(direct) + down + up; + const float floatReference = juce::jmax(1.0f, direct); + const float expectedGain = floatReference / juce::jmax( + floatReference, direct + down + up); + result.maximumExcitationFloatCoefficientDifference = juce::jmax( + result.maximumExcitationFloatCoefficientDifference, + std::abs(gain - expectedGain)); + const float error = static_cast(std::abs( + static_cast(gain) - reference / juce::jmax(reference, sum))); + result.maximumExcitationCoefficientError = juce::jmax( + result.maximumExcitationCoefficientError, error); + const double epsilonBudget = + std::numeric_limits::epsilon() * reference; + const double excess = juce::jmax(0.0, + static_cast(gain) * sum - reference); + result.maximumExcitationBudgetExcessEpsilons = juce::jmax( + result.maximumExcitationBudgetExcessEpsilons, + static_cast(excess / epsilonBudget)); + // Two rounded float additions and a rounded float division + // contribute less than two float epsilons relative error. + // Keep an exact float-coefficient check above; the double + // oracle measures numerical residual, not a different law. + coefficientBudgetPassed = coefficientBudgetPassed + && std::isfinite(gain) && gain > 0.0f && gain <= 1.0f + && excess <= 2.0 * epsilonBudget; + } + start += count; + ++partition; + } + }; + std::vector plainLeft, plainRight, unusedGains; + std::vector regularLeft, regularRight, regularGains; + std::vector unevenLeft, unevenRight, unevenGains; + render({ 257 }, false, plainLeft, plainRight, unusedGains); + render({ 257 }, true, regularLeft, regularRight, regularGains); + render({ 1, 7, 31, 193, 3, 1024, 11 }, true, unevenLeft, unevenRight, unevenGains); + result.maximumExcitationMetadataPartitionDifference = + maximumAbsoluteDifference(regularGains, unevenGains); + result.maximumExcitationAudioDifference = juce::jmax( + maximumAbsoluteDifference(plainLeft, regularLeft), + maximumAbsoluteDifference(plainRight, regularRight)); + result.maximumExcitationAudioDifference = juce::jmax( + result.maximumExcitationAudioDifference, + juce::jmax(maximumAbsoluteDifference(regularLeft, unevenLeft), + maximumAbsoluteDifference(regularRight, unevenRight))); + + constexpr int fastCount = 37; + std::array gains {}; + const auto hasUnityMetadata = [&] () + { + return std::all_of(gains.begin(), gains.begin() + fastCount, + [] (float gain) { return gain == 1.0f; }) + && gains.back() == -9.0f; + }; + bool fastPathsPassed = true; + const auto fastProcessor = std::make_unique(); + juce::AudioBuffer fastAudio(2, fastCount); + fastAudio.clear(); + gains.fill(-9.0f); + fastProcessor->processBlock(fastAudio, gains.data()); // Unprepared. + fastPathsPassed = fastPathsPassed && hasUnityMetadata(); + for (const float direct : { 0.0f, 0.5f, 1.0f, 1.25f }) + { + fastProcessor->setLevels(direct, 0.0f, 0.0f); + fastProcessor->prepare(safeSampleRate, fastCount); + gains.fill(-9.0f); + fastProcessor->processBlock(fastAudio, gains.data()); + fastPathsPassed = fastPathsPassed && hasUnityMetadata(); + } + juce::AudioBuffer noChannels(0, fastCount); + gains.fill(-9.0f); + fastProcessor->processBlock(noChannels, gains.data()); + fastPathsPassed = fastPathsPassed && hasUnityMetadata(); + // Metadata is control-only, including when the incoming audio is silent. + fastProcessor->setLevels(1.0f, 1.0f, 1.0f); + fastProcessor->prepare(safeSampleRate, fastCount); + gains.fill(-9.0f); + fastProcessor->processBlock(fastAudio, gains.data()); + for (int sample = 0; sample < fastCount; ++sample) + fastPathsPassed = fastPathsPassed && gains[static_cast(sample)] == 1.0f / 3.0f; + fastPathsPassed = fastPathsPassed && gains.back() == -9.0f; + juce::AudioBuffer noSamples(2, 0); + gains.fill(-9.0f); + fastProcessor->processBlock(noSamples, gains.data()); + fastPathsPassed = fastPathsPassed && gains.front() == -9.0f; + result.excitationCoefficientBudgetPassed = coefficientBudgetPassed; + result.excitationFastPathsPassed = fastPathsPassed; + result.excitationMetadataPassed = coefficientBudgetPassed && fastPathsPassed + && result.maximumExcitationFloatCoefficientDifference == 0.0f + && result.maximumExcitationMetadataPartitionDifference == 0.0f + && result.maximumExcitationAudioDifference == 0.0f; + } + { constexpr int sampleCount = 257; juce::AudioBuffer buffer(maximumChannels, sampleCount); @@ -1568,6 +1979,7 @@ NAMPolyOctaver::SelfTestResult NAMPolyOctaver::runDeterministicSelfTest( && result.stopbandRejectionDb <= -70.0f; } + result.lifecycle = runLifecycleSelfTest(safeSampleRate); result.passed = result.exactBypassPassed && result.exactSilencePassed && result.resetDeterminismPassed @@ -1579,6 +1991,8 @@ NAMPolyOctaver::SelfTestResult NAMPolyOctaver::runDeterministicSelfTest( && result.bassLowNotePassed && result.liveProfileSwitchPassed && result.liveProfileSwitchPartitionPassed - && result.stopbandRejectionPassed; + && result.stopbandRejectionPassed + && result.excitationMetadataPassed + && result.lifecycle.passed; return result; } diff --git a/Source/NAMPolyOctaver.h b/Source/NAMPolyOctaver.h index f4f05dd..0d0f7de 100644 --- a/Source/NAMPolyOctaver.h +++ b/Source/NAMPolyOctaver.h @@ -62,6 +62,23 @@ class NAMPolyOctaver final float lastBlockUpPeak = 0.0f; }; + struct LifecycleTestResult + { + bool passed = false; + bool retirementPartitionPassed = false; + bool unaffectedVoicePassed = false; + bool retiredProfileRecallPassed = false; + bool fuzzPairPartitionPassed = false; + int retirementComparisons = 0; + int profileComparisons = 0; + int fuzzPairComparisons = 0; + float maximumRetirementDifference = 0.0f; + float maximumMetadataDifference = 0.0f; + float maximumUnaffectedVoiceDifference = 0.0f; + float maximumRetiredProfileDifference = 0.0f; + float maximumFuzzPairDifference = 0.0f; + }; + struct SelfTestResult { bool passed = false; @@ -77,6 +94,9 @@ class NAMPolyOctaver final bool liveProfileSwitchPassed = false; bool liveProfileSwitchPartitionPassed = false; bool stopbandRejectionPassed = false; + bool excitationMetadataPassed = false; + bool excitationCoefficientBudgetPassed = false; + bool excitationFastPathsPassed = false; float maximumResetDifference = 0.0f; float maximumPartitionDifference = 0.0f; float maximumSilentChannelLeak = 0.0f; @@ -86,7 +106,13 @@ class NAMPolyOctaver final float maximumProfileSwitchDelta = 0.0f; float maximumProfileSwitchPartitionDifference = 0.0f; float stopbandRejectionDb = 0.0f; + float maximumExcitationCoefficientError = 0.0f; + float maximumExcitationFloatCoefficientDifference = 0.0f; + float maximumExcitationBudgetExcessEpsilons = 0.0f; + float maximumExcitationMetadataPartitionDifference = 0.0f; + float maximumExcitationAudioDifference = 0.0f; std::uint64_t nonFiniteRecoveries = 0; + LifecycleTestResult lifecycle; }; NAMPolyOctaver() noexcept; @@ -109,8 +135,13 @@ class NAMPolyOctaver final /** 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& buffer) noexcept; + /** In-place production mixer: Direct + Octave Down + Octave Up. + Optional excitationGains receives one control-only nonlinear-drive gain + per frame from the exact smoothed levels used by that frame. It must + hold buffer.getNumSamples() floats. The audio mix is never normalized; + direct-only, silent-control and unprepared paths write unity gains. */ + void processBlock(juce::AudioBuffer& buffer, + float* excitationGains = nullptr) noexcept; /** * Allocation-free raw-voice hook for deterministic headless tests. @@ -136,6 +167,8 @@ class NAMPolyOctaver final */ [[nodiscard]] static SelfTestResult runDeterministicSelfTest( double sampleRate = 48000.0); + [[nodiscard]] static LifecycleTestResult runLifecycleSelfTest( + double sampleRate = 48000.0); [[nodiscard]] bool isPrepared() const noexcept { @@ -256,6 +289,7 @@ class NAMPolyOctaver final void designFilterBank(double sampleRate) noexcept; void resetDspState() noexcept; + void retireWetPath() noexcept; void synchroniseLevelTargets() noexcept; void synchroniseProfileTargets() noexcept; VoiceFrame processVoiceSample(int channel, diff --git a/Source/NAMRackCpuRegression.h b/Source/NAMRackCpuRegression.h new file mode 100644 index 0000000..e63454e --- /dev/null +++ b/Source/NAMRackCpuRegression.h @@ -0,0 +1,1139 @@ +#pragma once + +#include "BuiltInEffects2.h" +#include "RegressionMessagePump.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if JUCE_WINDOWS && JUCE_DEBUG +#include +#endif + +// Opt-in offline timing. Independent complete processors run serially as tracks +// do, but this excludes device scheduling, the host graph and WebView overhead. +// Deadline misses and levels are diagnostics, never sound-quality assertions. +namespace NAMRackCpuRegression +{ +#if JUCE_WINDOWS && JUCE_DEBUG +namespace CallbackHeapProbe +{ +inline thread_local bool active = false; +inline thread_local int allocations = 0, frees = 0; +inline _CRT_ALLOC_HOOK previous = nullptr; +inline int hook(int kind, void* data, size_t size, int blockType, long request, const unsigned char* file, int line) +{ + if (active) + { + if (kind == _HOOK_ALLOC || kind == _HOOK_REALLOC) ++allocations; + if (kind == _HOOK_FREE) ++frees; + } + return previous != nullptr ? previous(kind, data, size, blockType, request, file, line) : 1; +} +} +#endif +// Frozen pre-optimization oracle. Keep its per-tap bounds/index lookup and +// scheduler independent of the production contiguous-span implementation. +template +int referenceResample(State& state, const float* input, int inputCount, + float* output, int capacity, const Kernel& kernel) +{ + if (input == nullptr || output == nullptr || inputCount < 0 || capacity < 0) + return -1; + constexpr int taps = 48, phases = 512, historySize = 128; + const auto sampleAt = [&] (std::int64_t absoluteIndex) + { + if (absoluteIndex < 0 || absoluteIndex >= state.totalInputSamples) return 0.0f; + const auto age = state.totalInputSamples - 1 - absoluteIndex; + if (age < 0 || age >= historySize) return 0.0f; + int index = state.writeIndex - 1 - static_cast(age); + while (index < 0) index += historySize; + return state.history[static_cast(index)]; + }; + int produced = 0; + for (int sample = 0; sample < inputCount; ++sample) + { + state.history[static_cast(state.writeIndex)] = std::isfinite(input[sample]) ? input[sample] : 0.0f; + state.writeIndex = (state.writeIndex + 1) % historySize; + ++state.totalInputSamples; + for (;;) + { + if (kernel.usesExactIntegerRatePhase ? state.nextSourceWhole >= state.totalInputSamples + : static_cast(state.totalOutputSamples) * kernel.sourceStep >= static_cast(state.totalInputSamples)) break; + if (produced >= capacity) return -1; + const double source = kernel.usesExactIntegerRatePhase + ? static_cast(state.nextSourceWhole) + static_cast(state.nextSourceRemainder) * kernel.inverseSourceStepDenominator + : static_cast(state.totalOutputSamples) * kernel.sourceStep; + const double delayed = source - taps / 2; + const auto base = static_cast(std::floor(delayed)); + const double fraction = delayed - static_cast(base); + const int phase = juce::jlimit(0, phases - 1, static_cast(fraction * phases)); + double sum = 0.0; + for (int tap = 0; tap < taps; ++tap) + sum += static_cast(sampleAt(base + tap - (taps / 2 - 1))) + * static_cast(kernel.coefficients[static_cast(phase * taps + tap)]); + output[produced++] = static_cast(sum); + ++state.totalOutputSamples; + if (kernel.usesExactIntegerRatePhase) + { + state.nextSourceWhole += static_cast(kernel.sourceStepWhole); + state.nextSourceRemainder += kernel.sourceStepRemainder; + if (state.nextSourceRemainder >= kernel.sourceStepDenominator) + { + state.nextSourceRemainder -= kernel.sourceStepDenominator; + ++state.nextSourceWhole; + } + } + } + } + return produced; +} + +template +bool sameResamplerState(const State& first, const State& second) +{ + return std::memcmp(first.history.data(), second.history.data(), first.history.size() * sizeof(float)) == 0 + && first.writeIndex == second.writeIndex && first.totalInputSamples == second.totalInputSamples + && first.totalOutputSamples == second.totalOutputSamples && first.nextSourceWhole == second.nextSourceWhole + && first.nextSourceRemainder == second.nextSourceRemainder; +} + +template +juce::var runResamplerParity() +{ + constexpr std::array, 8> rates {{ + {{44100, 48000}}, {{48000, 44100}}, {{48000, 48000}}, {{44100, 96000}}, + {{96000, 44100}}, {{44100.25, 48000.5}}, {{32000, 192000}}, {{192000, 8000}} + }}; + const std::array, 4> partitions {{ {1}, {8}, {16, 31, 127, 64, 3}, {512} }}; + std::vector input(4097); + std::uint32_t random = 0x29b6c7d5u; + for (size_t index = 0; index < input.size(); ++index) + { + random = random * 1664525u + 1013904223u; + input[index] = static_cast(0.18 * std::sin(static_cast(index) * 0.073) + + 0.03 * (static_cast(random & 0xffffu) / 32767.5 - 1.0)); + } + input[0] = 0.7f; + input[17] = std::numeric_limits::quiet_NaN(); + input[1999] = std::numeric_limits::infinity(); + input[3000] = -std::numeric_limits::infinity(); + input[4000] = -0.0f; + bool passed = true; + int comparedCalls = 0, comparedSamples = 0; + juce::Array rows; + for (const auto& rate : rates) + { + auto kernel = std::make_unique(); + kernel->prepare(rate[0], rate[1]); + bool ratePassed = kernel->coefficients.size() == 48u * 512u; + std::vector canonical; + for (size_t pattern = 0; pattern < partitions.size(); ++pattern) + { + State production, reference; + ratePassed = ratePassed && production.history.size() == 128; + for (int resetPass = 0; resetPass < 2; ++resetPass) + { + production.reset(); reference.reset(); + std::vector captured; + size_t cursor = 0, partition = 0; + while (cursor < input.size()) + { + const int count = juce::jmin(partitions[pattern][partition++ % partitions[pattern].size()], static_cast(input.size() - cursor)); + const int capacity = static_cast(std::ceil(count * rate[1] / rate[0])) + 8; + std::vector actual(static_cast(capacity + 4), -0.8125f), expected(actual); + const int got = production.process(input.data() + cursor, count, actual.data(), capacity, *kernel); + const int wanted = referenceResample(reference, input.data() + cursor, count, expected.data(), capacity, *kernel); + ratePassed = ratePassed && got == wanted && got >= 0 + && std::memcmp(actual.data(), expected.data(), actual.size() * sizeof(float)) == 0 + && sameResamplerState(production, reference); + if (got >= 0) + { + captured.insert(captured.end(), actual.begin(), actual.begin() + got); + comparedSamples += got; + } + ++comparedCalls; + cursor += static_cast(count); + } + if (canonical.empty()) canonical = captured; + ratePassed = ratePassed && canonical.size() == captured.size() + && std::memcmp(canonical.data(), captured.data(), captured.size() * sizeof(float)) == 0; + } + } + // Capacity failures can leave a consumed input sample and a pending + // output. Compare that exact state, canaries and subsequent recovery. + for (int capacity : {0, 1}) + { + State production, reference; + std::array actual {}, expected {}; + actual.fill(-0.6875f); expected = actual; + const int got = production.process(input.data(), 257, actual.data(), capacity, *kernel); + const int wanted = referenceResample(reference, input.data(), 257, expected.data(), capacity, *kernel); + ratePassed = ratePassed && got == -1 && wanted == -1 && actual == expected && sameResamplerState(production, reference); + const int resumed = production.process(input.data() + 257, 7, actual.data(), 128, *kernel); + const int expectedResumed = referenceResample(reference, input.data() + 257, 7, expected.data(), 128, *kernel); + ratePassed = ratePassed && resumed == expectedResumed && actual == expected && sameResamplerState(production, reference); + } + { + State production, reference; + std::array actual {}, expected {}; + ratePassed = ratePassed && production.process(nullptr, 8, actual.data(), 8, *kernel) == -1 + && referenceResample(reference, nullptr, 8, expected.data(), 8, *kernel) == -1 + && production.process(input.data(), 8, nullptr, 8, *kernel) == -1 + && production.process(input.data(), -1, actual.data(), 8, *kernel) == -1 + && production.process(input.data(), 8, actual.data(), -1, *kernel) == -1 + && sameResamplerState(production, reference); + } + passed = passed && ratePassed; + auto* row = new juce::DynamicObject(); + row->setProperty("inputRate", rate[0]); row->setProperty("outputRate", rate[1]); + row->setProperty("usesExactIntegerRatePhase", kernel->usesExactIntegerRatePhase); + row->setProperty("pass", ratePassed); rows.add(row); + } + auto* result = new juce::DynamicObject(); + result->setProperty("pass", passed); result->setProperty("rates", rows); + result->setProperty("comparedCalls", comparedCalls); result->setProperty("comparedSamples", comparedSamples); + result->setProperty("comparison", "Bit-exact pre-optimization reference output, full output canaries, ring history/cursors and phase; four partitions and two reset passes per rate, including nonfinite input, startup, wrap and capacity-failure recovery."); + return result; +} + +inline void addCheck(juce::Array& checks, const char* id, + const char* status, const juce::String& detail, + const juce::var& value = {}) +{ + auto* check = new juce::DynamicObject(); + check->setProperty("id", id); + check->setProperty("status", status); + check->setProperty("detail", detail); + if (!value.isVoid()) check->setProperty("value", value); + checks.add(check); +} + +template +juce::var runInactiveDelayParity(ProcessStage processStage, ReadState readState) +{ + bool passed = true; + juce::Array rows; + const auto configure = [] (OpenStudioNAMRack& rack, int profile) + { + rack.instrumentProfile.store(static_cast(profile)); + rack.delayTimeMs.store(7.0f); rack.delayFeedback.store(0.15f); + rack.delayMix.store(0.4f); rack.delayMod.store(0.3f); + rack.delayDucker.store(0.0f); rack.delayMode.store(0.0f); + rack.delayPingPong.store(0.0f); rack.delayTempoSync.store(0.0f); + }; + const auto sameAudio = [] (const juce::AudioBuffer& a, const juce::AudioBuffer& b) + { + if (a.getNumSamples() != b.getNumSamples()) return false; + for (int channel = 0; channel < 2; ++channel) + if (std::memcmp(a.getReadPointer(channel), b.getReadPointer(channel), static_cast(a.getNumSamples()) * sizeof(float)) != 0) return false; + return true; + }; + for (double rate : {44100.0, 48000.0, 96000.0}) for (int profile : {0, 1}) + { + auto dormant = std::make_unique(); + dormant->delayEnabled.store(0.0f); + dormant->prepareToPlay(rate, 128); + juce::AudioBuffer block(2, 128), expected(2, 128); + juce::MidiBuffer midi; + bool dryExact = true; + for (int count : {1, 8, 31, 64, 127}) + { + block.setSize(2, count, false, false, true); + expected.setSize(2, count, false, false, true); + dormant->delayTimeMs.store(std::numeric_limits::quiet_NaN()); + dormant->delayMix.store(0.9f); dormant->delayMode.store(3.0f); + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < count; ++sample) + block.setSample(channel, sample, (channel == 0 ? 0.17f : -0.13f) * std::sin(0.1f * sample)); + expected.makeCopyOf(block, true); + processStage(*dormant, block, midi); + const auto state = readState(*dormant); + dryExact = dryExact && sameAudio(block, expected) && state[0] == 0 && state[1] == 0 && state[2] == 0; + } + configure(*dormant, profile); + dormant->delayEnabled.store(1.0f); + auto fresh = std::make_unique(); + configure(*fresh, profile); fresh->delayEnabled.store(1.0f); + fresh->prepareToPlay(rate, 128); + block.setSize(2, 128, false, false, true); + expected.setSize(2, 128, false, false, true); + bool activationExact = true, drainExact = true, tailObserved = false; + for (int index = 0; index < 8; ++index) + { + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < 128; ++sample) + block.setSample(channel, sample, 0.15f * std::sin(0.09f * static_cast(index * 128 + sample))); + expected.makeCopyOf(block, true); + processStage(*dormant, block, midi); processStage(*fresh, expected, midi); + activationExact = activationExact && sameAudio(block, expected); + } + const bool tailWasScheduled = readState(*dormant)[0] > 0; + dormant->delayEnabled.store(0.0f); fresh->delayEnabled.store(0.0f); + for (int sample = 0; sample < static_cast(rate * 0.5); sample += 128) + { + block.clear(); expected.clear(); + processStage(*dormant, block, midi); processStage(*fresh, expected, midi); + drainExact = drainExact && sameAudio(block, expected) && readState(*dormant) == readState(*fresh); + tailObserved = tailObserved || block.getMagnitude(0, 128) > 1.0e-8f; + } + const bool tailCompleted = readState(*dormant)[0] == 0 && readState(*dormant)[1] == 0; + const bool okay = dryExact && activationExact && drainExact && tailWasScheduled && tailObserved && tailCompleted; + passed = passed && okay; + auto* row = new juce::DynamicObject(); + row->setProperty("sampleRate", rate); row->setProperty("instrumentProfile", profile); + row->setProperty("disabledDryExact", dryExact); row->setProperty("offEditsActivateLikeFresh", activationExact); + row->setProperty("tailDrainExact", drainExact); row->setProperty("tailObserved", tailObserved); + row->setProperty("tailCompleted", tailCompleted); row->setProperty("pass", okay); rows.add(row); + } + auto* result = new juce::DynamicObject(); + result->setProperty("pass", passed); result->setProperty("cases", rows); + result->setProperty("comparison", "Exact disabled dry output and zero-tail bookkeeping, followed by off-edited engagement and retained-tail drain matching a fresh processor with the selected settings. No subjective audio assertion."); + return result; +} + +template +juce::var runDriveCacheParity(Invalidate invalidate) +{ + bool passed = true; + juce::Array rows; + for (int factor : {2, 4, 8}) for (int profile : {0, 1}) for (int channels : {1, 2}) + { + auto cached = std::make_unique(); + auto reference = std::make_unique(); + for (double rate : {44100.0, 48000.0, 96000.0, 44100.0}) + { + for (auto* rack : {cached.get(), reference.get()}) + { + rack->instrumentProfile.store(static_cast(profile)); + rack->setEmbeddedDriveOversamplingFactor(factor); + rack->setRoutedInputChannelCount(channels); + rack->ampEnabled.store(0); rack->pedalMix.store(0); rack->setCabRequestedEnabled(false); + rack->compressorEnabled.store(0); rack->octaverEnabled.store(0); rack->fuzzEnabled.store(0); + rack->preEqEnabled.store(0); rack->eqEnabled.store(0); rack->modulatorEnabled.store(0); + rack->delayEnabled.store(0); rack->reverbEnabled.store(0); rack->gateThresholdDb.store(-100); + rack->precisionDriveEnabled.store(1); rack->chaosEnabled.store(1); + rack->precisionDriveVoice.store(0); rack->precisionDriveDrive.store(0.3f); + rack->precisionDriveVolumeDb.store(0); rack->precisionDriveGate.store(0); + rack->chaosDrive.store(0.4f); rack->chaosMode.store(0); rack->chaosGate.store(0); + rack->calibrationReferenceDbu.store(12); + rack->setPlayConfigDetails(channels, channels, rate, 512); + rack->prepareToPlay(rate, 512); rack->reset(); + } + juce::AudioBuffer actual(channels, 512), expected(channels, 512); + juce::MidiBuffer midi; + constexpr std::array partitions {1, 7, 31, 128, 512}; + struct SingleControlEdit + { + const char* id; + std::atomic OpenStudioNAMRack::* control; + float value; + }; + // Follow the original simultaneous-control/lifecycle sequence with + // isolated changes. A missing cache-key dependency must fail even + // when every other key is held constant. + const std::array isolatedEdits {{ + {"precisionDriveDrive", &OpenStudioNAMRack::precisionDriveDrive, 0.47f}, + {"precisionDriveAttack", &OpenStudioNAMRack::precisionDriveAttack, 0.19f}, + {"precisionDriveBright", &OpenStudioNAMRack::precisionDriveBright, 0.37f}, + {"precisionDriveVolumeDb", &OpenStudioNAMRack::precisionDriveVolumeDb, -7.0f}, + {"chaosMode", &OpenStudioNAMRack::chaosMode, 0.0f}, + {"chaosDrive", &OpenStudioNAMRack::chaosDrive, 0.29f}, + {"chaosTone", &OpenStudioNAMRack::chaosTone, 0.21f}, + {"chaosWeight", &OpenStudioNAMRack::chaosWeight, 0.28f}, + {"chaosLevelDb", &OpenStudioNAMRack::chaosLevelDb, 4.0f}, + {"calibrationReferenceDbu", &OpenStudioNAMRack::calibrationReferenceDbu, 6.0f}, + {"instrumentProfile", &OpenStudioNAMRack::instrumentProfile, static_cast(profile)}, + {"legacyVoiceOn", &OpenStudioNAMRack::precisionDriveVoice, 1.0f}, + {"legacyVoiceOff", &OpenStudioNAMRack::precisionDriveVoice, 0.0f}, + {"precisionPowerOff", &OpenStudioNAMRack::precisionDriveEnabled, 0.0f}, + {"precisionOffStateDrive", &OpenStudioNAMRack::precisionDriveDrive, 0.61f}, + {"precisionPowerOn", &OpenStudioNAMRack::precisionDriveEnabled, 1.0f}, + {"distortionPowerOff", &OpenStudioNAMRack::chaosEnabled, 0.0f}, + {"distortionOffStateDrive", &OpenStudioNAMRack::chaosDrive, 0.63f}, + {"distortionPowerOn", &OpenStudioNAMRack::chaosEnabled, 1.0f}, + }}; + constexpr int eventSpacing = 2048; + constexpr int combinedEvents = 12; + const int length = (combinedEvents + static_cast(isolatedEdits.size())) * eventSpacing; + int cursor = 0, block = 0; + bool exact = true, finite = true; + while (cursor < length) + { + if (cursor % eventSpacing == 0) + { + const int event = cursor / eventSpacing; + for (auto* rack : {cached.get(), reference.get()}) + { + if (event >= combinedEvents) + { + const auto& edit = isolatedEdits[static_cast(event - combinedEvents)]; + (rack->*edit.control).store(edit.value); + continue; + } + rack->precisionDriveDrive.store(event % 2 ? 0.83f : 0.13f); + rack->precisionDriveAttack.store(static_cast(event % 3) * 0.4f); + rack->precisionDriveBright.store(static_cast(event % 4) * 0.3f); + rack->precisionDriveVolumeDb.store(event % 2 ? 5.0f : -3.0f); + rack->precisionDriveVoice.store(event == 8 || event == 9 ? 1.0f : 0.0f); + rack->precisionDriveEnabled.store(event == 4 || event == 5 || event == 7 ? 0.0f : 1.0f); + rack->chaosEnabled.store(event == 7 ? 0.0f : 1.0f); + rack->chaosMode.store(static_cast(event % 3)); + rack->chaosDrive.store(event % 2 ? 0.7f : 0.2f); + rack->chaosTone.store(static_cast(event % 3) * 0.4f); + rack->chaosWeight.store(static_cast(event % 3) * 0.45f); + rack->chaosLevelDb.store(event % 2 ? -5.0f : 2.0f); + rack->calibrationReferenceDbu.store(event >= 3 ? 18.0f : 12.0f); + rack->instrumentProfile.store(static_cast(event >= 2 ? 1 - profile : profile)); + if (event == 10) rack->reset(); + } + } + const int count = juce::jmin(partitions[static_cast(block++ % 5)], eventSpacing - cursor % eventSpacing); + actual.setSize(channels, count, false, false, true); + expected.setSize(channels, count, false, false, true); + for (int channel = 0; channel < channels; ++channel) for (int sample = 0; sample < count; ++sample) + { + const float value = static_cast(0.11 * std::sin((cursor + sample) * (channel == 0 ? 0.019 : 0.031))); + actual.setSample(channel, sample, value); expected.setSample(channel, sample, value); + } + invalidate(*reference); + cached->processBlock(actual, midi); reference->processBlock(expected, midi); + for (int channel = 0; channel < channels; ++channel) + { + exact = exact && std::memcmp(actual.getReadPointer(channel), expected.getReadPointer(channel), static_cast(count) * sizeof(float)) == 0; + for (int sample = 0; sample < count; ++sample) finite = finite && std::isfinite(actual.getSample(channel, sample)); + } + cursor += count; + } + const auto cachedState = cached->getDiagnosticState(), referenceState = reference->getDiagnosticState(); + const auto designs = [] (const juce::var& state) + { return static_cast(state.getProperty("precisionDriveCoefficientDesignCount", 0)) + + static_cast(state.getProperty("distortionCoefficientDesignCount", 0)); }; + const bool reduced = designs(cachedState) > 0 && designs(cachedState) < designs(referenceState); + const bool layoutMatches = cached->getTotalNumInputChannels() == channels + && cached->getTotalNumOutputChannels() == channels + && reference->getTotalNumInputChannels() == channels + && reference->getTotalNumOutputChannels() == channels; + const bool valid = exact && finite && reduced && layoutMatches; + passed = passed && valid; + auto* row = new juce::DynamicObject(); + row->setProperty("sampleRate", rate); row->setProperty("oversamplingFactor", factor); + row->setProperty("initialProfile", profile); row->setProperty("channels", channels); + row->setProperty("preparedInputChannels", cached->getTotalNumInputChannels()); + row->setProperty("preparedOutputChannels", cached->getTotalNumOutputChannels()); + row->setProperty("layoutMatchesBuffer", layoutMatches); + row->setProperty("combinedLifecycleEvents", combinedEvents); + juce::StringArray isolatedIds; + for (const auto& edit : isolatedEdits) isolatedIds.add(edit.id); + row->setProperty("isolatedControlEdits", isolatedIds.joinIntoString(",")); + row->setProperty("audioExact", exact); row->setProperty("finite", finite); + row->setProperty("cachedDesigns", designs(cachedState)); row->setProperty("uncachedDesigns", designs(referenceState)); + row->setProperty("pass", valid); rows.add(row); + } + } + auto* result = new juce::DynamicObject(); + result->setProperty("pass", passed); result->setProperty("cases", rows); + result->setProperty("comparison", "48 rows:2x/4x/8x, Guitar/Bass and negotiated mono/stereo buses with matching buffers, each re-prepared at44.1/48/96/44.1k. Every row retains12 combined control/lifecycle events, then19 one-control-at-a-time edits covering each control/profile cache dependency, gain/calibration, historical voice and individual-pedal bypass/off-edit/re-entry. Effective-rate coverage uses each prepared factor and repeated host-rate transitions. Cached and forced-redesign audio must remain exact while design calls decrease."); + return result; +} + +inline juce::var withOptimizationContracts(juce::var result, const juce::var& resampler, const juce::var& delay, const juce::var& drive) +{ + auto* object = result.getDynamicObject(); + if (object == nullptr) return result; + auto checks = object->getProperty("checks"); + if (!checks.isArray()) checks = juce::Array(); + addCheck(*checks.getArray(), "nam_resampler_contiguous_history_reference_parity", resampler.getProperty("pass", false) ? "pass" : "fail", + "Optimized resampling must exactly match the frozen pre-optimization tap/scheduling reference through startup, ring wrap, reset, integer/fractional rates, callback partitions and capacity failures.", resampler); + addCheck(*checks.getArray(), "nam_inactive_delay_macro_bypass_contract", delay.getProperty("pass", false) ? "pass" : "fail", + "Disabled Delay with no tail must retain exact dry output and bookkeeping; off-state edits must engage like a fresh processor and existing tails must continue draining.", delay); + addCheck(*checks.getArray(), "nam_drive_coefficient_cache_parity", drive.getProperty("pass", false) ? "pass" : "fail", + "Cached Drive/Distortion must exactly match forced coefficient redesign through control and lifecycle changes while reducing design calls.", drive); + object->setProperty("checks", checks); + const bool passed = object->getProperty("objectiveGateStatus").toString() == "pass" + && static_cast(resampler.getProperty("pass", false)) && static_cast(delay.getProperty("pass", false)) + && static_cast(drive.getProperty("pass", false)); + object->setProperty("objectiveGateStatus", passed ? "pass" : "fail"); + object->setProperty("success", passed); object->setProperty("done", passed); + return result; +} + +inline void fillInput(juce::AudioBuffer& audio, int start, + double sampleRate, int routedChannels) +{ + for (int sample = 0; sample < audio.getNumSamples(); ++sample) + { + const double time = static_cast(start + sample) / sampleRate; + const float left = static_cast(0.22 * std::sin(juce::MathConstants::twoPi * 173.0 * time) + + 0.05 * std::sin(juce::MathConstants::twoPi * 3417.0 * time)); + const float right = routedChannels == 1 ? left + : static_cast(0.19 * std::sin(juce::MathConstants::twoPi * 229.0 * time) + + 0.04 * std::cos(juce::MathConstants::twoPi * 5171.0 * time)); + audio.setSample(0, sample, left); + audio.setSample(1, sample, right); + } +} + +// Explicit local scenario manifests keep user-preset qualification separate +// from the bundled-model stress fixture. All file access, model publication, +// allocation and report construction happen outside the timed callback. +inline juce::var runScenarios(const juce::File& manifestFile) +{ + auto* report = new juce::DynamicObject(); + const juce::var result(report); + juce::Array checks, rows; + bool passed = true; + int capacityFailures = 0; + report->setProperty("schemaVersion", 1); + report->setProperty("summary", "Explicit preset scenarios, serial independent Racks, fixed audio duration and shuffled repeats. Driver, host graph and UI scheduling excluded."); + report->setProperty("timingStatus", "diagnostic_only"); + report->setProperty("subjectiveQuality", "not_asserted"); + report->setProperty("manifestPath", manifestFile.getFullPathName()); + #if JUCE_DEBUG + report->setProperty("buildConfiguration", "Debug"); + #else + report->setProperty("buildConfiguration", "Release"); + #endif + const auto finish = [&] + { + report->setProperty("checks", checks); + report->setProperty("cases", rows); + report->setProperty("casesWithDeadlineMisses", capacityFailures); + report->setProperty("capacityStatus", !passed || rows.isEmpty() ? "not_asserted" : capacityFailures == 0 ? "pass" : "fail"); + report->setProperty("capacityScope", "Zero measured offline deadline misses, not physical-device qualification or a universal realtime guarantee."); + report->setProperty("objectiveGateStatus", passed ? "pass" : "fail"); + report->setProperty("success", passed); + report->setProperty("done", passed); + return result; + }; + const auto config = juce::JSON::parse(manifestFile); + const auto cases = config.getProperty("cases", {}); + const auto number = [] (const juce::var& value) + { + return (value.isInt() || value.isInt64() || value.isDouble()) + && std::isfinite(static_cast(value)); + }; + const auto integer = [&] (const juce::var& value) + { + if (!number(value)) return false; + const double numericValue = static_cast(value); + return numericValue == std::floor(numericValue) && numericValue >= -2147483647.0 && numericValue <= 2147483647.0; + }; + const auto repeatsValue = config.getProperty("repeats", 3); + const auto warmupValue = config.getProperty("warmupSeconds", 0.5); + const auto durationValue = config.getProperty("measurementSeconds", 1.0); + const int repeats = integer(repeatsValue) ? static_cast(repeatsValue) : 0; + const double warmup = number(warmupValue) ? static_cast(warmupValue) : 0.0; + const double duration = number(durationValue) ? static_cast(durationValue) : 0.0; + const bool requestHeapProbe = static_cast(config.getProperty("probeCallbackHeap", false)); + bool heapMeasured = false; + #if JUCE_WINDOWS && JUCE_DEBUG + heapMeasured = requestHeapProbe; + if (heapMeasured) CallbackHeapProbe::previous = _CrtSetAllocHook(CallbackHeapProbe::hook); + const juce::ScopeGuard restoreHeapHook {[&] + { + CallbackHeapProbe::active = false; + if (heapMeasured) _CrtSetAllocHook(CallbackHeapProbe::previous); + }}; + #else + juce::ignoreUnused(requestHeapProbe); + #endif + report->setProperty("callbackHeapProbe", heapMeasured ? "MSVC Debug CRT allocations/frees on processing thread, including warmup" : "not_asserted"); + if (!manifestFile.existsAsFile() || config.getDynamicObject() == nullptr + || !integer(config.getProperty("schemaVersion", {})) + || static_cast(config.getProperty("schemaVersion", 0)) != 1 + || !config.getProperty("probeCallbackHeap", false).isBool() + || !config.getProperty("exportDirectory", "").isString() + || !cases.isArray() || cases.size() < 1 || cases.size() > 512 + || repeats < 1 || repeats > 10 || !std::isfinite(warmup) || warmup < 0.1 || warmup > 10.0 + || !std::isfinite(duration) || duration < 0.1 || duration > 30.0) + { + passed = false; + addCheck(checks, "nam_cpu_scenario_configuration", "fail", "Manifest needs schemaVersion 1, 1-512 cases, integer repeats 1-10, numeric warmupSeconds 0.1-10 and measurementSeconds 0.1-30, optional boolean probeCallbackHeap and string exportDirectory."); + return finish(); + } + report->setProperty("manifestSha256", juce::SHA256(manifestFile).toHexString()); + report->setProperty("warmupSeconds", warmup); + report->setProperty("measurementSeconds", duration); + report->setProperty("repeats", repeats); + juce::StringArray caseIds; + for (const auto& scenario : *cases.getArray()) + { + const auto id = scenario.getProperty("id", {}).toString(); + if (scenario.getDynamicObject() == nullptr || !scenario.getProperty("id", {}).isString() + || id.isEmpty() || id.length() > 128 || id != id.retainCharacters("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + || caseIds.contains(id, true)) + { + passed = false; + addCheck(checks, "nam_cpu_scenario_ids", "fail", "Case IDs must be unique portable filename components (letters, digits, hyphen, underscore)."); + return finish(); + } + caseIds.add(id); + } + const auto prototype = std::make_unique(); + juce::MemoryBlock defaultState; + prototype->getStateInformation(defaultState); + const auto supportedState = juce::ValueTree::readFromData(defaultState.getData(), defaultState.getSize()); + std::vector order(static_cast(cases.size())); + std::iota(order.begin(), order.end(), 0); + std::mt19937 random(0x4e414d32u); + for (int repeat = 0; repeat < repeats; ++repeat) + { + std::shuffle(order.begin(), order.end(), random); + for (int caseIndex : order) + { + const auto& scenario = cases[caseIndex]; + const auto id = scenario.getProperty("id", {}).toString(); + const auto rackConfigs = scenario.getProperty("racks", {}); + const auto pattern = scenario.getProperty("bufferPattern", {}); + const auto rateValue = scenario.getProperty("sampleRate", {}); + const auto channelsValue = scenario.getProperty("routedInputChannels", 1); + const auto factorValue = scenario.getProperty("oversamplingFactor", 4); + const double rate = number(rateValue) ? static_cast(rateValue) : 0.0; + const int channels = integer(channelsValue) ? static_cast(channelsValue) : 0; + const int factor = integer(factorValue) ? static_cast(factorValue) : 0; + int maximumBlock = 0; + int minimumBlock = 2048; + bool valid = id.isNotEmpty() && rackConfigs.isArray() && rackConfigs.size() >= 1 && rackConfigs.size() <= 4 + && pattern.isArray() && pattern.size() > 0 && pattern.size() <= 32 + && std::isfinite(rate) && rate >= 8000.0 && rate <= 192000.0 + && (channels == 1 || channels == 2) && (factor == 2 || factor == 4 || factor == 8); + for (int index = 0; index < pattern.size(); ++index) + { + const int size = integer(pattern[index]) ? static_cast(pattern[index]) : 0; + valid = valid && size >= 1 && size <= 2048; + maximumBlock = juce::jmax(maximumBlock, size); + minimumBlock = juce::jmin(minimumBlock, size); + } + if (const auto* rackArray = rackConfigs.getArray()) + for (const auto& rackConfig : *rackArray) + { + const auto input = rackConfig.getProperty("input", "guitar"); + const auto presetPath = rackConfig.getProperty("presetPath", {}); + valid = valid && rackConfig.getDynamicObject() != nullptr && presetPath.isString() + && presetPath.toString().trim().isNotEmpty() && input.isString() + && (input.toString() == "guitar" || input.toString() == "bass" || input.toString() == "silence") + && (!rackConfig.hasProperty("overrides") + || rackConfig.getProperty("overrides", {}).getDynamicObject() != nullptr); + } + if (!valid) + { + passed = false; + addCheck(checks, "nam_cpu_scenario_configuration", "fail", "Invalid case: " + id); + continue; + } + std::vector> racks; + std::vector> audio; + juce::Array evidence; + const int warmupFrames = static_cast(std::ceil(rate * warmup)); + const int requestedMeasuredFrames = static_cast(std::ceil(rate * duration)); + std::vector callbackSizes; + int warmupCursor = 0; + while (warmupCursor < warmupFrames) + { + const int count = juce::jmin(static_cast(pattern[static_cast(callbackSizes.size() % static_cast(pattern.size()))]), warmupFrames - warmupCursor); + callbackSizes.push_back(count); warmupCursor += count; + } + int measuredFrames = 0; + while (measuredFrames < requestedMeasuredFrames) + { + const int count = static_cast(pattern[static_cast(callbackSizes.size() % static_cast(pattern.size()))]); + callbackSizes.push_back(count); measuredFrames += count; + } + const int totalFrames = warmupFrames + measuredFrames; + const auto exportRoot = config.getProperty("exportDirectory", {}).toString(); + const bool exportAudio = exportRoot.isNotEmpty() && repeat == 0; + for (const auto& rackConfig : *rackConfigs.getArray()) + { + const auto preset = manifestFile.getParentDirectory().getChildFile(rackConfig.getProperty("presetPath", {}).toString()); + juce::MemoryBlock payload; + bool migrated = false; + bool loaded = preset.existsAsFile() && preset.loadFileAsData(payload) + && OpenStudioNAMRack::migratePresetStateToCurrent(payload, migrated); + auto tree = juce::ValueTree::readFromData(payload.getData(), payload.getSize()); + loaded = loaded && tree.hasType("OpenStudioNAMRack"); + const auto overrides = rackConfig.getProperty("overrides", {}); + loaded = loaded && (overrides.isVoid() || overrides.getDynamicObject() != nullptr); + if (const auto* properties = overrides.getDynamicObject()) + { + for (const auto& property : properties->getProperties()) + { + const auto supported = supportedState.getProperty(property.name); + const bool numeric = (number(property.value) || property.value.isBool()) + && (number(supported) || supported.isBool()) + && std::abs(static_cast(property.value)) <= std::numeric_limits::max(); + loaded = loaded && numeric; + if (numeric && tree.isValid()) tree.setProperty(property.name, property.value, nullptr); + } + } + juce::MemoryBlock requested; + { + juce::MemoryOutputStream stateStream(requested, false); + tree.writeToStream(stateStream); + } + auto rack = std::make_unique(); + rack->setEmbeddedDriveOversamplingFactor(factor); + rack->prepareToPlay(rate, maximumBlock); + loaded = loaded && rack->restoreTonePresetStateInformation(requested.getData(), static_cast(requested.getSize())); + rack->setRoutedInputChannelCount(channels); + rack->reset(); + rack->resetModelSnapshotLockMissCount(); + const auto ampPath = tree.getProperty("ampModelPath").toString(); + const auto pedalPath = tree.getProperty("pedalModelPath").toString(); + const auto cabPath = tree.getProperty("cabIRPath").toString(); + loaded = loaded && rack->hasAmpModel() && rack->getAmpModelPath() == ampPath + && (pedalPath.isEmpty() || (rack->hasPedalModel() && rack->getPedalModelPath() == pedalPath)) + && (cabPath.isEmpty() || (rack->hasCabIR() && rack->getCabIRPath() == cabPath)); + auto* resource = new juce::DynamicObject(); + resource->setProperty("presetPath", preset.getFullPathName()); + resource->setProperty("presetSha256", preset.existsAsFile() ? juce::SHA256(preset).toHexString() : juce::String()); + resource->setProperty("migrated", migrated); + resource->setProperty("requestedOverrides", overrides); + resource->setProperty("ampExpected", static_cast(tree.getProperty("ampEnabled", 1.0f)) >= 0.5f); + resource->setProperty("pedalExpected", tree.getProperty("pedalModelPath").toString().isNotEmpty() + && static_cast(tree.getProperty("pedalMix", 1.0f)) > 0.0f); + resource->setProperty("ampExecuted", false); + resource->setProperty("pedalExecuted", false); + resource->setProperty("firstAmpExecutionBlock", -1); + resource->setProperty("firstPedalExecutionBlock", -1); + const auto requestedXML = tree.createXml(); + resource->setProperty("requestedState", requestedXML != nullptr ? requestedXML->toString() : juce::String()); + juce::MemoryBlock actualState; + rack->getStateInformation(actualState); + const auto actualTree = juce::ValueTree::readFromData(actualState.getData(), actualState.getSize()); + bool overridesMatch = true; + if (const auto* properties = overrides.getDynamicObject()) + for (const auto& property : properties->getProperties()) + overridesMatch = overridesMatch && actualTree.hasProperty(property.name) + && static_cast(actualTree.getProperty(property.name)) == static_cast(property.value); + loaded = loaded && overridesMatch; + resource->setProperty("overridesReadbackMatched", overridesMatch); + const auto actualXML = actualTree.createXml(); + resource->setProperty("actualState", actualXML != nullptr ? actualXML->toString() : juce::String()); + for (const auto* key : {"ampModelPath", "pedalModelPath", "cabIRPath"}) + { + const auto path = tree.getProperty(key).toString(); + if (path.isEmpty()) continue; + const auto file = juce::File(path); + resource->setProperty(juce::String(key) + "Sha256", file.existsAsFile() ? juce::SHA256(file).toHexString() : juce::String()); + loaded = loaded && file.existsAsFile(); + } + resource->setProperty("resourcesLoaded", loaded); + valid = valid && loaded; + evidence.add(resource); + racks.push_back(std::move(rack)); + audio.emplace_back(2, totalFrames); + const auto input = rackConfig.getProperty("input", "guitar").toString(); + const bool bass = input == "bass"; + valid = valid && (bass || input == "guitar" || input == "silence"); + resource->setProperty("input", input + " synthetic diagnostic, not a recorded performance"); + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < totalFrames; ++sample) + { + const double time = sample / rate; + const double fundamental = bass ? 30.8677063 : 82.4068892; + const double stereo = channels == 2 && channel == 1 ? 1.334839854 : 1.0; + const float value = input == "silence" ? 0.0f : static_cast(0.15 * std::sin(juce::MathConstants::twoPi * fundamental * stereo * time) + + 0.035 * std::sin(juce::MathConstants::twoPi * fundamental * 7.0 * stereo * time)); + audio.back().setSample(channel, sample, value); + } + juce::Array inputHashes; + for (int channel = 0; channel < 2; ++channel) + inputHashes.add(juce::SHA256(audio.back().getReadPointer(channel), static_cast(totalFrames) * sizeof(float)).toHexString()); + resource->setProperty("inputChannelSha256", inputHashes); + } + if (!valid) + { + passed = false; + addCheck(checks, "nam_cpu_scenario_resources", "fail", "Preset restore, resource identity or override validation failed: " + id, evidence); + continue; + } + std::vector times, loads; + times.reserve(static_cast(measuredFrames / minimumBlock + 1)); + loads.reserve(static_cast(measuredFrames / minimumBlock + 1)); + int allocations = 0, frees = 0; + int cursor = 0, blockIndex = 0, misses = 0, nonFinite = 0; + float individualPeak = 0.0f, summedPeak = 0.0f; + juce::MidiBuffer midi; + while (cursor < totalFrames) + { + const int count = callbackSizes[static_cast(blockIndex++)]; + // Buffer views use JUCE's fixed internal pointer capacity for two + // lanes; their construction is outside the measured DSP region. + std::array, 4> pointers {}; + std::array, 4> blocks; + for (size_t index = 0; index < racks.size(); ++index) + { + pointers[index] = { audio[index].getWritePointer(0, cursor), audio[index].getWritePointer(1, cursor) }; + blocks[index].setDataToReferTo(pointers[index].data(), 2, count); + } + #if JUCE_WINDOWS && JUCE_DEBUG + CallbackHeapProbe::allocations = CallbackHeapProbe::frees = 0; + CallbackHeapProbe::active = heapMeasured; + #endif + const auto started = juce::Time::getHighResolutionTicks(); + for (size_t index = 0; index < racks.size(); ++index) racks[index]->processBlock(blocks[index], midi); + const double microseconds = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - started) * 1.0e6; + #if JUCE_WINDOWS && JUCE_DEBUG + CallbackHeapProbe::active = false; + allocations += CallbackHeapProbe::allocations; frees += CallbackHeapProbe::frees; + #endif + // A one-sample callback can legitimately produce zero model-rate + // frames. Observe warmup until every expected model first executes, + // instead of relying on the first/last callback's phase alignment. + // All diagnostic reads remain outside the timed region. + if (cursor < warmupFrames) + for (size_t index = 0; index < racks.size(); ++index) + { + auto* witness = evidence.getReference(static_cast(index)).getDynamicObject(); + const bool needAmp = static_cast(witness->getProperty("ampExpected")) + && !static_cast(witness->getProperty("ampExecuted")); + const bool needPedal = static_cast(witness->getProperty("pedalExpected")) + && !static_cast(witness->getProperty("pedalExecuted")); + if (!needAmp && !needPedal) continue; + const auto state = racks[index]->getDiagnosticState(); + if (needAmp && static_cast(state.getProperty("lastAmpDspFrames", 0)) > 0) + { + witness->setProperty("ampExecuted", true); + witness->setProperty("firstAmpExecutionBlock", blockIndex - 1); + } + if (needPedal && static_cast(state.getProperty("lastPedalDspFrames", 0)) > 0) + { + witness->setProperty("pedalExecuted", true); + witness->setProperty("firstPedalExecutionBlock", blockIndex - 1); + } + } + if (cursor >= warmupFrames) + { + const double budget = count / rate * 1.0e6; + times.push_back(microseconds); + loads.push_back(microseconds / budget); + if (microseconds > budget) ++misses; + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < count; ++sample) + { + float sum = 0.0f; + for (size_t index = 0; index < racks.size(); ++index) + { + const float value = blocks[index].getSample(channel, sample); + if (!std::isfinite(value)) ++nonFinite; + else { individualPeak = juce::jmax(individualPeak, std::abs(value)); sum += value; } + } + summedPeak = juce::jmax(summedPeak, std::abs(sum)); + } + } + cursor += count; + pumpRegressionMessages(); + } + for (size_t index = 0; index < racks.size(); ++index) + { + auto* resource = evidence.getReference(static_cast(index)).getDynamicObject(); + const auto diagnostics = racks[index]->getDiagnosticState(); + resource->setProperty("diagnostics", diagnostics); + valid = valid && (!static_cast(resource->getProperty("ampExpected")) || static_cast(resource->getProperty("ampExecuted"))) + && (!static_cast(resource->getProperty("pedalExpected")) || static_cast(resource->getProperty("pedalExecuted"))); + valid = valid && !static_cast(diagnostics.getProperty("realtimeDSPBlocked", true)) + && static_cast(diagnostics.getProperty("embeddedDriveActiveOversamplingFactor", 0)) == factor; + for (const auto* key : {"modelProcessFailCount", "audioThreadResizeAvoidedCount", "oversizeBypassCount", "modelSnapshotLockMissCount", "realtimeSafetyBypassCount", "rackOutputEmergencyMuteTripCount", "inputNonFiniteSampleCount"}) + valid = valid && static_cast(diagnostics.getProperty(key, -1)) == 0; + juce::Array hashes; + for (int channel = 0; channel < 2; ++channel) + hashes.add(juce::SHA256(audio[index].getReadPointer(channel), static_cast(totalFrames) * sizeof(float)).toHexString()); + resource->setProperty("outputChannelSha256", hashes); + if (exportAudio) + { + const auto folder = manifestFile.getParentDirectory().getChildFile(exportRoot); + const auto file = folder.getChildFile(id.retainCharacters("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + "-rack-" + juce::String(static_cast(index) + 1) + ".wav"); + juce::WavAudioFormat format; + // Export only to a newly created review directory; never + // overwrite a prior fixture or the user's source recording. + bool written = folder.createDirectory().wasOk() && !file.exists(); + std::unique_ptr stream(written ? file.createOutputStream().release() : nullptr); + std::unique_ptr writer; + if (stream != nullptr) writer = format.createWriterFor(stream, + juce::AudioFormatWriterOptions().withSampleRate(rate).withNumChannels(2).withBitsPerSample(32) + .withSampleFormat(juce::AudioFormatWriterOptions::SampleFormat::floatingPoint)); + if (writer != nullptr) written = writer->writeFromAudioSampleBuffer(audio[index], 0, totalFrames); + else written = false; + valid = valid && written; + resource->setProperty("exportPath", file.getFullPathName()); + } + } + valid = valid && nonFinite == 0 && !times.empty() && allocations == 0 && frees == 0; + passed = passed && valid; + if (misses > 0) ++capacityFailures; + const double mean = std::accumulate(times.begin(), times.end(), 0.0) / static_cast(times.size()); + std::sort(times.begin(), times.end()); + std::sort(loads.begin(), loads.end()); + const auto percentile = [] (const std::vector& values, double p) + { return values[juce::jmin(values.size() - 1, static_cast(std::ceil(p * static_cast(values.size()))) - 1)]; }; + auto* row = new juce::DynamicObject(); + row->setProperty("id", id); row->setProperty("repeat", repeat); row->setProperty("configuration", scenario); + row->setProperty("racks", evidence); row->setProperty("processingValid", valid); + row->setProperty("measuredBlocks", static_cast(times.size())); + row->setProperty("measuredFrames", measuredFrames); row->setProperty("warmupFrames", warmupFrames); + row->setProperty("averageMicroseconds", mean); row->setProperty("p99Microseconds", percentile(times, 0.99)); + row->setProperty("p999Microseconds", percentile(times, 0.999)); row->setProperty("maximumMicroseconds", times.back()); + row->setProperty("p99DeadlineLoad", percentile(loads, 0.99)); row->setProperty("maximumDeadlineLoad", loads.back()); + row->setProperty("deadlineMisses", misses); row->setProperty("capacityStatus", !valid ? "not_asserted" : misses == 0 ? "pass" : "fail"); + row->setProperty("measuredSeconds", measuredFrames / rate); + row->setProperty("individualOutputPeak", individualPeak); row->setProperty("summedOutputPeak", summedPeak); + row->setProperty("nonFiniteCount", nonFinite); rows.add(row); + row->setProperty("callbackHeapMeasured", heapMeasured); + row->setProperty("callbackAllocations", allocations); row->setProperty("callbackFrees", frees); + } + } + passed = passed && rows.size() == cases.size() * repeats; + addCheck(checks, "nam_cpu_scenario_processing_integrity", passed ? "pass" : "fail", "Exact preset/resource restore, independent input streams, finite output and no processing fallback; deadline capacity is reported separately."); + return finish(); +} + +template +juce::var run(FindModel findModel, ConfigureRack configureRack, WriteIR writeIR) +{ + const double started = juce::Time::getMillisecondCounterHiRes(); + const auto blockCountText = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_TWO_RACK_CPU_BLOCKS", {}); + const int measuredBlocks = blockCountText.isEmpty() ? 1024 : blockCountText.getIntValue(); + const bool countValid = measuredBlocks >= 128 && measuredBlocks <= 16384 + && (blockCountText.isEmpty() || blockCountText == juce::String(measuredBlocks)); + auto* report = new juce::DynamicObject(); + const juce::var result(report); + report->setProperty("schemaVersion", 1); + report->setProperty("subjectiveQuality", "not_asserted"); + report->setProperty("timingStatus", "diagnostic_only"); + report->setProperty("audioDeviceOpened", false); + report->setProperty("summary", "Two independent complete NAM racks processed serially; offline warmed CPU diagnostics, excluding driver scheduling and host/UI overhead."); + report->setProperty("oversamplingScope", "4x applies to the embedded Precision/Chaos drive island; NAM models retain their own sample-rate policy."); + report->setProperty("input", "Deterministic continuous two-tone input, duplicated to stereo for mono routes; both racks receive the same source. No additional normalization or master limiter; production rack safety guards remain active."); + report->setProperty("measuredBlocksPerCase", measuredBlocks); + report->setProperty("preparedMaximumBlockSize", 128); + #if JUCE_DEBUG + report->setProperty("buildConfiguration", "Debug"); + #else + report->setProperty("buildConfiguration", "Release"); + #endif + juce::Array checks, rows, modelEvidence; + bool integrityPassed = countValid; + const auto finish = [&] + { + report->setProperty("checks", checks); + report->setProperty("cases", rows); + report->setProperty("models", modelEvidence); + int casesWithMisses = 0; + for (const auto& row : rows) + if (static_cast(row.getProperty("deadlineMisses", 0)) > 0) ++casesWithMisses; + report->setProperty("casesWithDeadlineMisses", casesWithMisses); + report->setProperty("capacityStatus", !integrityPassed || rows.isEmpty() ? "not_asserted" : casesWithMisses == 0 ? "pass" : "fail"); + report->setProperty("capacityScope", "Zero measured offline callback deadline misses; separate from signal integrity and physical-device stability."); + report->setProperty("expectedCaseCount", 60); + report->setProperty("objectiveGateStatus", integrityPassed ? "pass" : "fail"); + report->setProperty("success", integrityPassed); + report->setProperty("done", integrityPassed); + report->setProperty("elapsedMilliseconds", juce::Time::getMillisecondCounterHiRes() - started); + return result; + }; + if (!countValid) + { + addCheck(checks, "two_rack_cpu_configuration", "fail", "OPENSTUDIO_NAM_TWO_RACK_CPU_BLOCKS must be an integer from 128 to 16384."); + return finish(); + } + + const auto resolveModel = [&] (const char* variable, const juce::File& fallback) + { + const auto requested = juce::SystemStats::getEnvironmentVariable(variable, {}).trim().unquoted(); + return requested.isEmpty() ? fallback : juce::File::getCurrentWorkingDirectory().getChildFile(requested); + }; + const auto amp = resolveModel("OPENSTUDIO_NAM_BENCHMARK_AMP_MODEL", findModel("wavenet_a2_max.nam")); + const auto pedal = resolveModel("OPENSTUDIO_NAM_BENCHMARK_PEDAL_MODEL", findModel("wavenet_a1_standard.nam")); + const std::array ampFiles {amp, resolveModel("OPENSTUDIO_NAM_BENCHMARK_AMP_MODEL_2", amp)}; + const std::array pedalFiles {pedal, resolveModel("OPENSTUDIO_NAM_BENCHMARK_PEDAL_MODEL_2", pedal)}; + for (size_t index = 0; index < ampFiles.size(); ++index) + { + const bool available = ampFiles[index].existsAsFile() && pedalFiles[index].existsAsFile(); + integrityPassed = integrityPassed && available; + auto* model = new juce::DynamicObject(); + model->setProperty("rack", static_cast(index) + 1); + model->setProperty("ampPath", ampFiles[index].getFullPathName()); + model->setProperty("pedalPath", pedalFiles[index].getFullPathName()); + model->setProperty("ampSha256", ampFiles[index].existsAsFile() ? juce::SHA256(ampFiles[index]).toHexString() : juce::String()); + model->setProperty("pedalSha256", pedalFiles[index].existsAsFile() ? juce::SHA256(pedalFiles[index]).toHexString() : juce::String()); + modelEvidence.add(model); + } + const auto irFile = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("openstudio_two_rack_cpu_" + juce::Uuid().toString() + ".wav"); + const juce::ScopeGuard removeIR {[&] { irFile.deleteFile(); }}; + const bool irWritten = writeIR(irFile, false); + report->setProperty("cabIR", "Generated deterministic 2048-sample stereo impulse, authored at 44100 Hz by the existing NAM regression fixture helper."); + integrityPassed = integrityPassed && irWritten; + if (!integrityPassed) + { + addCheck(checks, "two_rack_cpu_fixtures", "fail", "Both model paths must exist and the generated cabinet IR must be writable."); + return finish(); + } + + struct StageSet { const char* name; bool complete; bool fuzz; }; + constexpr std::array stageSets {{ + {"amp_cab", false, false}, + {"complete", true, false}, + {"complete_with_fuzz", true, true} + }}; + for (const auto& stage : stageSets) + { + std::array, 2> racks; + bool loaded = true; + for (size_t index = 0; index < racks.size(); ++index) + { + racks[index] = std::make_unique(); + auto& rack = *racks[index]; + rack.setEmbeddedDriveOversamplingFactor(4); + configureRack(rack); + rack.auditionSource.store(0.0f); + rack.compressorEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.octaverEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.fuzzEnabled.store(stage.fuzz ? 1.0f : 0.0f); + rack.fuzzDrive.store(0.5f); + rack.fuzzVoice.store(0.0f); + rack.fuzzLevelDb.store(-4.0f); + rack.precisionDriveEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.chaosEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.eqEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.cabRoomEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.cabRoomAmount.store(0.42f); + rack.cabDoublerEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.cabDoublerMix.store(0.24f); + rack.modulatorEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.delayEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.reverbEnabled.store(stage.complete ? 1.0f : 0.0f); + rack.setCabRequestedEnabled(true); + rack.ampEnabled.store(1.0f); + rack.ampMix.store(1.0f); + rack.prepareToPlay(44100.0, 128); + const bool ampLoaded = rack.loadAmpModel(ampFiles[index].getFullPathName()); + const bool pedalLoaded = !stage.complete || rack.loadPedalModel(pedalFiles[index].getFullPathName()); + const bool cabLoaded = rack.loadCabIR(irFile.getFullPathName()); + loaded = loaded && ampLoaded && pedalLoaded && cabLoaded; + if (!ampLoaded || !pedalLoaded || !cabLoaded) + addCheck(checks, "two_rack_cpu_model_load", "fail", juce::String(stage.name) + ": " + rack.getLastLoadError()); + } + if (!loaded) + { + integrityPassed = false; + continue; + } + + for (double rate : {44100.0, 48000.0}) + { + for (auto& rack : racks) rack->prepareToPlay(rate, 128); + for (int channels : {1, 2}) for (int blockSize : {8, 16, 32, 64, 128}) + { + juce::Logger::writeToLog("[twoRackCpu] " + juce::String(stage.name) + " rate=" + + juce::String(rate, 0) + " channels=" + juce::String(channels) + " block=" + juce::String(blockSize)); + for (auto& rack : racks) + { + rack->setRoutedInputChannelCount(channels); + rack->reset(); + } + std::array, 2> audio; + for (auto& block : audio) block.setSize(2, blockSize); + juce::MidiBuffer midi; + const int warmupBlocks = static_cast(std::ceil(rate * 0.12 / blockSize)); + int cursor = 0; + std::vector times; + times.reserve(static_cast(measuredBlocks)); + int nonFinite = 0, deadlineMisses = 0; + float outputPeak = 0.0f, summedPeak = 0.0f; + const double deadline = blockSize / rate * 1.0e6; + const double dispatchStarted = juce::Time::getMillisecondCounterHiRes(); + for (int blockIndex = -warmupBlocks; blockIndex < measuredBlocks; ++blockIndex) + { + for (auto& block : audio) fillInput(block, cursor, rate, channels); + cursor += blockSize; + const auto ticks = juce::Time::getHighResolutionTicks(); + for (size_t index = 0; index < racks.size(); ++index) racks[index]->processBlock(audio[index], midi); + const double elapsed = juce::Time::highResolutionTicksToSeconds(juce::Time::getHighResolutionTicks() - ticks) * 1.0e6; + if (blockIndex >= 0) + { + times.push_back(elapsed); + if (elapsed > deadline) ++deadlineMisses; + for (int channel = 0; channel < 2; ++channel) for (int sample = 0; sample < blockSize; ++sample) + { + const float left = audio[0].getSample(channel, sample), right = audio[1].getSample(channel, sample); + if (!std::isfinite(left) || !std::isfinite(right)) ++nonFinite; + else + { + outputPeak = juce::jmax(outputPeak, std::abs(left), std::abs(right)); + summedPeak = juce::jmax(summedPeak, std::abs(left + right)); + } + } + } + pumpRegressionMessages(); // Never included in the timed region. + } + const double totalWallMs = juce::Time::getMillisecondCounterHiRes() - dispatchStarted; + std::sort(times.begin(), times.end()); + const auto percentile = [&] (double p) + { + const size_t index = static_cast(std::ceil(p * static_cast(times.size()))) - 1; + return times[juce::jmin(index, times.size() - 1)]; + }; + const double average = std::accumulate(times.begin(), times.end(), 0.0) / measuredBlocks; + bool valid = nonFinite == 0 && outputPeak > 1.0e-7f; + juce::Array rackEvidence; + for (const auto& rack : racks) + { + const auto state = rack->getDiagnosticState(); + auto* evidence = new juce::DynamicObject(); + for (const auto* key : {"activeInputRoutingMode", "trueStereoDualNAMActive", "ampModelSampleRate", "pedalModelSampleRate", "lastAmpDspFrames", "lastPedalDspFrames", "modelProcessFailCount", "realtimeSafetyBypassCount", "audioThreadResizeAvoidedCount", "oversizeBypassCount", "modelSnapshotLockMissCount", "realtimeDSPBlocked", "embeddedDriveActiveOversamplingFactor", "rackOutputEmergencyMuteTripCount", "rackOutputSafetyGuardHitCount", "rackMaximumRawOutputPeakLinear"}) + evidence->setProperty(key, state.getProperty(key, {})); + const bool routed = static_cast(state.getProperty("activeInputRoutingMode", -1)) == (channels == 2 ? 2 : 0); + const bool rackValid = routed && static_cast(state.getProperty("lastAmpDspFrames", 0)) > 0 + && (!stage.complete || static_cast(state.getProperty("lastPedalDspFrames", 0)) > 0) + && static_cast(state.getProperty("embeddedDriveActiveOversamplingFactor", 0)) == 4 + && !static_cast(state.getProperty("realtimeDSPBlocked", true)) + && static_cast(state.getProperty("modelProcessFailCount", -1)) == 0 + && static_cast(state.getProperty("realtimeSafetyBypassCount", -1)) == 0 + && static_cast(state.getProperty("audioThreadResizeAvoidedCount", -1)) == 0 + && static_cast(state.getProperty("oversizeBypassCount", -1)) == 0 + && static_cast(state.getProperty("modelSnapshotLockMissCount", -1)) == 0 + && static_cast(state.getProperty("rackOutputEmergencyMuteTripCount", -1)) == 0; + valid = valid && rackValid; + evidence->setProperty("processingValid", rackValid); + rackEvidence.add(evidence); + } + integrityPassed = integrityPassed && valid; + auto* row = new juce::DynamicObject(); + row->setProperty("stageSet", stage.name); + row->setProperty("sampleRate", rate); + row->setProperty("bufferSize", blockSize); + row->setProperty("routedInputChannels", channels); + row->setProperty("rackCount", 2); + row->setProperty("embeddedDriveOversamplingFactor", 4); + row->setProperty("warmupBlocks", warmupBlocks); + row->setProperty("averageMicroseconds", average); + row->setProperty("medianMicroseconds", percentile(0.5)); + row->setProperty("p99Microseconds", percentile(0.99)); + row->setProperty("p999Microseconds", percentile(0.999)); + row->setProperty("maximumMicroseconds", times.back()); + row->setProperty("callbackDeadlineMicroseconds", deadline); + row->setProperty("deadlineMisses", deadlineMisses); + row->setProperty("capacityStatus", !valid ? "not_asserted" : deadlineMisses == 0 ? "pass" : "fail"); + row->setProperty("singleCoreAverageLoadPercent", average / deadline * 100.0); + row->setProperty("wallMillisecondsIncludingWarmupAndDispatch", totalWallMs); + row->setProperty("nonFiniteCount", nonFinite); + row->setProperty("individualOutputPeak", outputPeak); + row->setProperty("summedOutputPeak", summedPeak); + row->setProperty("processingValid", valid); + row->setProperty("timingStatus", "diagnostic_only"); + row->setProperty("racks", rackEvidence); + rows.add(row); + } + } + } + integrityPassed = integrityPassed && rows.size() == 60; + addCheck(checks, "two_rack_cpu_processing_integrity", integrityPassed ? "pass" : "fail", + "All 60 cases must load independent model instances, process the requested mono/stereo route at 4x drive oversampling, remain finite and non-silent, and avoid NAM failure, resize, oversize, snapshot or safety fallbacks."); + addCheck(checks, "two_rack_cpu_callback_matrix", "diagnostic_only", + "44.1/48 kHz x 8/16/32/64/128 samples x mono/stereo x amp+cab/full/full+fuzz; timings and summed peaks exclude hardware/host/UI scheduling and do not establish audible crackle or tone quality."); + return finish(); +} +} diff --git a/Source/OpenStudioPitchCorrector.cpp b/Source/OpenStudioPitchCorrector.cpp index 68425f3..b1afb58 100644 --- a/Source/OpenStudioPitchCorrector.cpp +++ b/Source/OpenStudioPitchCorrector.cpp @@ -12,26 +12,30 @@ OpenStudioPitchCorrector::OpenStudioPitchCorrector() void OpenStudioPitchCorrector::prepareToPlay(double sampleRate, int samplesPerBlock) { + samplesPerBlock = juce::jmax(1, samplesPerBlock); cachedSampleRate = sampleRate; + activeSignalPath = signalPath.load() >= .5f; + mapper.setCorrectedNoteMask(signalPath.load() >= .5f); + detector.setRateNormalized(signalPath.load() >= .5f); detector.prepare(sampleRate, samplesPerBlock); mapper.prepare(sampleRate); // Streaming latency includes both analysis and synthesis, plus the // library's split-computation scheduling delay. - stretcher.presetCheaper (2, static_cast (sampleRate)); + withPitchEngine([&](auto& engine) { engine.presetCheaper(2, static_cast(sampleRate)); }); // Apply detection params detector.setMinFrequency(minFreqParam.load()); detector.setMaxFrequency(maxFreqParam.load()); detector.setSensitivity(sensitivity.load()); - setLatencySamples (stretcher.inputLatency() + stretcher.outputLatency()); + setLatencySamples(withPitchEngine([](const auto& engine) { return engine.inputLatency() + engine.outputLatency(); })); dryDelay.setSize(2, getLatencySamples() + 1); wetMix.reset(sampleRate, 0.010); // Pre-allocate per-block scratch buffers so processBlock never heap-allocates. - // samplesPerBlock is the maximum; actual numSamples will always be <= this. + // Oversized direct/offline calls are divided into this prepared capacity. dryBuffer.setSize (2, samplesPerBlock, false, true, false); detectionBuffer.setSize(1, samplesPerBlock, false, true, false); stretchOutputBuf.setSize (2, samplesPerBlock, false, true, false); @@ -51,7 +55,8 @@ void OpenStudioPitchCorrector::reset() const float source = detectionSource.load(); activeDetectionSource = std::isfinite(source) ? juce::jlimit(0, 2, juce::roundToInt(source)) : 0; mapper.reset(); - stretcher.reset(); + withPitchEngine([](auto& engine) { engine.reset(); }); + formantSettingsValid = false; dryDelay.clear(); dryDelayPosition = 0; dryBuffer.clear(); @@ -73,6 +78,38 @@ void OpenStudioPitchCorrector::reset() } void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + const juce::ScopedNoDenormals noDenormals; + const int capacity = dryBuffer.getNumSamples(); + const int channels = juce::jmin(2, buffer.getNumChannels()); + if (capacity <= 0 || dryDelay.getNumSamples() <= 0) + { + buffer.clear(); + return; + } + // Mono/stereo are the only supported layouts. A defensive direct call + // must not index the two-channel staging buffers through extra channels. + for (int ch = channels; ch < buffer.getNumChannels(); ++ch) + buffer.clear(ch, 0, buffer.getNumSamples()); + if (channels == 0) return; + if (buffer.getNumSamples() <= capacity && buffer.getNumChannels() <= 2) + { + processPreparedBlock(buffer, midi, 0); + return; + } + for (int offset = 0; offset < buffer.getNumSamples(); offset += capacity) + { + const int count = juce::jmin(capacity, buffer.getNumSamples() - offset); + float* pointers[2] { buffer.getWritePointer(0, offset), + channels > 1 ? buffer.getWritePointer(1, offset) : nullptr }; + // JUCE's external-data constructor uses inline channel-pointer storage + // for this two-channel view; it neither owns nor allocates audio data. + juce::AudioBuffer part(pointers, channels, count); + processPreparedBlock(part, midi, offset); + } +} + +void OpenStudioPitchCorrector::processPreparedBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi, int midiOffset) { const int numSamples = buffer.getNumSamples(); const int numChannels = buffer.getNumChannels(); @@ -87,12 +124,14 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju activeDetectionSource = source; } - const int requestedMidiChannel = juce::jlimit(1, 16, static_cast(midiOutputChannel.load())); + const float channelValue = midiOutputChannel.load(std::memory_order_relaxed); + const int requestedMidiChannel = std::isfinite(channelValue) + ? static_cast(juce::jlimit(1.0f, 16.0f, channelValue)) : 1; const bool generateMidi = !bypassed && midiOutputEnabled.load() > 0.5f; if (currentMidiNote >= 0 && (midiResetPending || !generateMidi || requestedMidiChannel != currentMidiChannel)) { - midi.addEvent(juce::MidiMessage::noteOff(currentMidiChannel, currentMidiNote), 0); - midi.addEvent(juce::MidiMessage::pitchWheel(currentMidiChannel, 8192), 0); + midi.addEvent(juce::MidiMessage::noteOff(currentMidiChannel, currentMidiNote), midiOffset); + midi.addEvent(juce::MidiMessage::pitchWheel(currentMidiChannel, 8192), midiOffset); currentMidiNote = -1; midiNoteHoldTime = 0.0f; } @@ -106,15 +145,22 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju // Keep the dry delay warm even at 100% wet and during bypass. wetMix.setTargetValue(bypassed ? 0.0f : juce::jlimit(0.0f, 1.0f, mix.load())); - for (int i = 0; i < numSamples; ++i) + const int delayLength = dryDelay.getNumSamples(); + int nextPosition = dryDelayPosition; + for (int ch = 0; ch < numChannels; ++ch) { - for (int ch = 0; ch < numChannels; ++ch) + float* delayed = dryDelay.getWritePointer(ch); + float* dry = dryBuffer.getWritePointer(ch); + const float* input = buffer.getReadPointer(ch); + nextPosition = dryDelayPosition; + for (int i = 0; i < numSamples; ++i) { - dryDelay.setSample(ch, dryDelayPosition, buffer.getSample(ch, i)); - dryBuffer.setSample(ch, i, dryDelay.getSample(ch, (dryDelayPosition + 1) % dryDelay.getNumSamples())); + delayed[nextPosition] = input[i]; + if (++nextPosition == delayLength) nextPosition = 0; + dry[i] = delayed[nextPosition]; } - dryDelayPosition = (dryDelayPosition + 1) % dryDelay.getNumSamples(); } + dryDelayPosition = nextPosition; // Detection source is independent of the linked stereo correction path. const float* monoInput = buffer.getReadPointer(source == 1 && numChannels > 1 ? 1 : 0); @@ -148,10 +194,20 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju // Apply pitch shift via Signalsmith Stretch (real-time, native stereo). // Use pre-allocated inPtrs/outPtrs/stretchOutputBuf to avoid heap allocation. - stretcher.setTransposeFactor (ratio); - // Signalsmith frequencies are cycles per sample, not Hz. - stretcher.setFormantBase (detectedHz > 0.0f ? detectedHz / static_cast(cachedSampleRate) : 0.0f); - stretcher.setFormantSemitones (mapper.getFormantShift(), mapper.getFormantCorrection()); + withPitchEngine([&](auto& engine) { + engine.setTransposeFactor(ratio); + // Signalsmith frequencies are cycles per sample, not Hz. + engine.setFormantBase(detectedHz > 0 ? detectedHz / static_cast(cachedSampleRate) : 0); + const float shift = mapper.getFormantShift(); + const bool correction = mapper.getFormantCorrection(); + if (!formantSettingsValid || shift != appliedFormantShift || correction != appliedFormantCorrection) + { + engine.setFormantSemitones(shift, correction); + appliedFormantShift = shift; + appliedFormantCorrection = correction; + formantSettingsValid = true; + } + }); for (int ch = 0; ch < numChannels; ++ch) { @@ -168,11 +224,7 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju outPtrs[1] = stretchOutputBuf.getWritePointer(1); } - stretcher.process (inPtrs, numSamples, outPtrs, numSamples); - - for (int ch = 0; ch < numChannels; ++ch) - std::memcpy (buffer.getWritePointer (ch), stretchOutputBuf.getReadPointer (ch), - static_cast (numSamples) * sizeof (float)); + withPitchEngine([&](auto& engine) { engine.process(inPtrs, numSamples, outPtrs, numSamples); }); // Apply dry/wet mix for (int i = 0; i < numSamples; ++i) @@ -180,9 +232,10 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju const float mixVal = wetMix.getNextValue(); for (int ch = 0; ch < numChannels; ++ch) { - float* wet = buffer.getWritePointer(ch); + float* output = buffer.getWritePointer(ch); + const float* wet = stretchOutputBuf.getReadPointer(ch); const float* dry = dryBuffer.getReadPointer(ch); - wet[i] = dry[i] * (1.0f - mixVal) + wet[i] * mixVal; + output[i] = dry[i] * (1.0f - mixVal) + wet[i] * mixVal; } } @@ -206,7 +259,7 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju // MIDI output generation if (generateMidi) { - int midiCh = juce::jlimit(1, 16, static_cast(midiOutputChannel.load(std::memory_order_relaxed))) - 1; + const int midiCh = currentMidiChannel - 1; if (correctedHz > 0.0f && conf > 0.3f) { @@ -219,8 +272,8 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju // Note changed — send note-off for old, note-on for new if (midiNoteHoldTime >= midiMinHoldTime) { - midi.addEvent(juce::MidiMessage::noteOff(midiCh + 1, currentMidiNote), 0); - midi.addEvent(juce::MidiMessage::noteOn(midiCh + 1, targetNote, static_cast(velocity)), 0); + midi.addEvent(juce::MidiMessage::noteOff(midiCh + 1, currentMidiNote), midiOffset); + midi.addEvent(juce::MidiMessage::noteOn(midiCh + 1, targetNote, static_cast(velocity)), midiOffset); currentMidiNote = targetNote; currentMidiVelocity = velocity; midiNoteHoldTime = 0.0f; @@ -230,7 +283,7 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju else if (currentMidiNote < 0) { // No note sounding — start new note - midi.addEvent(juce::MidiMessage::noteOn(midiCh + 1, targetNote, static_cast(velocity)), 0); + midi.addEvent(juce::MidiMessage::noteOn(midiCh + 1, targetNote, static_cast(velocity)), midiOffset); currentMidiNote = targetNote; currentMidiVelocity = velocity; midiNoteHoldTime = 0.0f; @@ -242,12 +295,12 @@ void OpenStudioPitchCorrector::processBlock(juce::AudioBuffer& buffer, ju float bendSemitones = corMidi - static_cast(currentMidiNote); int bendValue = 8192 + static_cast(bendSemitones / 2.0f * 8191.0f); bendValue = juce::jlimit(0, 16383, bendValue); - midi.addEvent(juce::MidiMessage::pitchWheel(midiCh + 1, bendValue), 0); + midi.addEvent(juce::MidiMessage::pitchWheel(midiCh + 1, bendValue), midiOffset); } else if (currentMidiNote >= 0) { // No pitch detected — send note-off - midi.addEvent(juce::MidiMessage::noteOff(midiCh + 1, currentMidiNote), 0); + midi.addEvent(juce::MidiMessage::noteOff(midiCh + 1, currentMidiNote), midiOffset); currentMidiNote = -1; midiNoteHoldTime = 0.0f; } @@ -319,6 +372,7 @@ void OpenStudioPitchCorrector::getStateInformation(juce::MemoryBlock& destData) { juce::ValueTree state("OpenStudioPitchCorrector"); + state.setProperty("signalPath", signalPath.load(), nullptr); state.setProperty("key", mapper.getKey(), nullptr); state.setProperty("scale", static_cast(mapper.getScale()), nullptr); state.setProperty("retuneSpeed", mapper.getRetuneSpeed(), nullptr); @@ -348,8 +402,11 @@ void OpenStudioPitchCorrector::getStateInformation(juce::MemoryBlock& destData) void OpenStudioPitchCorrector::setStateInformation(const void* data, int sizeInBytes) { auto state = juce::ValueTree::readFromData(data, static_cast(sizeInBytes)); - if (!state.isValid()) return; + if (!state.isValid() || state.getType().toString() != "OpenStudioPitchCorrector") return; + signalPath.store(static_cast(state.getProperty("signalPath", 0.0f)) >= .5f ? 1.0f : 0.0f); + const bool changedSignalPath = activeSignalPath != (signalPath.load() >= .5f); + mapper.setCorrectedNoteMask(signalPath.load() >= .5f); mapper.setKey(state.getProperty("key", 0)); mapper.setScale(static_cast(static_cast(state.getProperty("scale", 0)))); mapper.setRetuneSpeed(state.getProperty("retuneSpeed", 50.0f)); @@ -371,6 +428,7 @@ void OpenStudioPitchCorrector::setStateInformation(const void* data, int sizeInB for (int i = 0; i < 12; ++i) mapper.setNoteEnabled(i, state.getProperty("noteEnable_" + juce::String(i), true)); + if (changedSignalPath && dryBuffer.getNumSamples() > 0) prepareToPlay(cachedSampleRate, dryBuffer.getNumSamples()); } juce::String OpenStudioPitchCorrector::midiToNoteName(float midiNote) diff --git a/Source/OpenStudioPitchCorrector.h b/Source/OpenStudioPitchCorrector.h index 526dd02..2a11811 100644 --- a/Source/OpenStudioPitchCorrector.h +++ b/Source/OpenStudioPitchCorrector.h @@ -82,6 +82,8 @@ class OpenStudioPitchCorrector : public juce::AudioProcessor // ---- Parameters (thread-safe) ---- + std::atomic signalPath {1}; // saved-state compatibility for mapper and detector + // Bypass pitch correction (pass-through) std::atomic bypass { 0.0f }; // 0=active, 1=bypassed @@ -102,8 +104,15 @@ class OpenStudioPitchCorrector : public juce::AudioProcessor PitchDetector detector; PitchMapper mapper; signalsmith::stretch::SignalsmithStretch stretcher; + signalsmith::stretch::SignalsmithStretch correctedStretcher; + template decltype(auto) withPitchEngine(Callback&& callback) + { + if (activeSignalPath) return callback(correctedStretcher); + return callback(stretcher); + } double cachedSampleRate = 44100.0; + bool activeSignalPath = true; std::atomic lastDetectedHz { 0.0f }; std::atomic lastCorrectedHz { 0.0f }; @@ -118,6 +127,9 @@ class OpenStudioPitchCorrector : public juce::AudioProcessor std::vector inPtrs; // Signalsmith input channel pointers std::vector outPtrs; // Signalsmith output channel pointers juce::AudioBuffer stretchOutputBuf; // Signalsmith output staging buffer + bool formantSettingsValid = false, appliedFormantCorrection = false; + float appliedFormantShift = 0; + void processPreparedBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi, int midiOffset); // Ring slots can be overwritten while the UI copies them. Atomic fields // avoid a data race; an even, unchanged generation identifies one frame. diff --git a/Source/PitchDetector.cpp b/Source/PitchDetector.cpp index 831dbcc..b357421 100644 --- a/Source/PitchDetector.cpp +++ b/Source/PitchDetector.cpp @@ -9,11 +9,35 @@ PitchDetector::PitchDetector() void PitchDetector::prepare(double sr, int /*maxBlockSize*/) { - sampleRate = sr; + decimation = rateNormalized ? juce::jmax(1, static_cast(std::ceil(sr / 32000.0))) : 1; + sampleRate = sr / decimation; + // Windowed-sinc decimator. Compute a convolution only for retained samples; + // storage and coefficients are prepared off the callback. At 192 kHz this + // keeps the same 64 ms YIN window and substantially reduces YIN work. + const int length = decimation > 1 ? 64 * decimation + 1 : 1; + antiAliasCoefficients.assign(static_cast(length), 0); + // Mirrored history makes the complete newest-to-oldest FIR window + // contiguous. The convolution keeps its original tap/accumulation order, + // but no longer branches to wrap the ring for every tap. + antiAliasHistory.assign(static_cast(2 * length), 0); + const double cutoff = juce::jmin(6000.0, sampleRate * .4) / sr; + double sum = 0; + for (int i = 0; i < length; ++i) + { + const int offset = i - length / 2; + const double x = 2 * juce::MathConstants::pi * cutoff * offset; + const double sinc = offset == 0 ? 2 * cutoff : std::sin(x) / (juce::MathConstants::pi * offset); + const double window = length == 1 ? 1.0 : .42 - .5 * std::cos(2 * juce::MathConstants::pi * i / (length - 1)) + .08 * std::cos(4 * juce::MathConstants::pi * i / (length - 1)); + antiAliasCoefficients[static_cast(i)] = static_cast(sinc * window); + sum += sinc * window; + } + for (auto& coefficient : antiAliasCoefficients) coefficient = static_cast(coefficient / sum); + decimationPosition = antiAliasPosition = 0; inputBuffer.resize(frameSize * 2, 0.0f); yinBuffer.resize(frameSize / 2, 0.0f); writePos = 0; samplesAccumulated = 0; + pendingTau = 0; detectedFreq.store(0.0f, std::memory_order_relaxed); confidence.store(0.0f, std::memory_order_relaxed); clearHistory(); @@ -21,9 +45,12 @@ void PitchDetector::prepare(double sr, int /*maxBlockSize*/) void PitchDetector::reset() { + std::fill(antiAliasHistory.begin(), antiAliasHistory.end(), 0.0f); + decimationPosition = antiAliasPosition = 0; std::fill(inputBuffer.begin(), inputBuffer.end(), 0.0f); writePos = 0; samplesAccumulated = 0; + pendingTau = 0; detectedFreq.store(0.0f, std::memory_order_relaxed); confidence.store(0.0f, std::memory_order_relaxed); clearHistory(); @@ -33,7 +60,22 @@ void PitchDetector::processSamples(const float* samples, int numSamples) { for (int i = 0; i < numSamples; ++i) { - inputBuffer[static_cast(writePos)] = samples[i]; + float sample = samples[i]; + if (decimation > 1) + { + const int length = static_cast(antiAliasCoefficients.size()); + antiAliasHistory[static_cast(antiAliasPosition)] = sample; + antiAliasHistory[static_cast(antiAliasPosition + length)] = sample; + if (++antiAliasPosition == length) antiAliasPosition = 0; + if (++decimationPosition < decimation) continue; + decimationPosition = 0; + sample = 0; + const float* newest = antiAliasHistory.data() + antiAliasPosition + length - 1; + for (int tap = 0; tap < length; ++tap) + sample += antiAliasCoefficients[static_cast(tap)] * newest[-tap]; + } + if (rateNormalized) advanceYIN(); + inputBuffer[static_cast(writePos)] = sample; writePos = (writePos + 1) % static_cast(inputBuffer.size()); ++samplesAccumulated; @@ -43,11 +85,10 @@ void PitchDetector::processSamples(const float* samples, int numSamples) // Extract frame from ring buffer auto& frame = analysisFrame; - int readPos = (writePos - frameSize + static_cast(inputBuffer.size())) % static_cast(inputBuffer.size()); - for (int j = 0; j < frameSize; ++j) - { - frame[static_cast(j)] = inputBuffer[static_cast((readPos + j) % static_cast(inputBuffer.size()))]; - } + const int readPos = (writePos - frameSize + static_cast(inputBuffer.size())) % static_cast(inputBuffer.size()); + const int first = juce::jmin(frameSize, static_cast(inputBuffer.size()) - readPos); + std::copy_n(inputBuffer.data() + readPos, first, frame.data()); + std::copy_n(inputBuffer.data(), frameSize - first, frame.data() + first); // Compute RMS float sumSq = 0.0f; @@ -66,13 +107,24 @@ void PitchDetector::processSamples(const float* samples, int numSamples) continue; } - // Run YIN - float freq = runYIN(frame.data(), frameSize); - - // Store result - float conf = confidence.load(std::memory_order_relaxed); // set by runYIN - - publishFrame(freq, conf, rmsDB); + if (rateNormalized) + { + // Spread at most two lag dot-products per retained input sample. + // Every legal search completes before the next 512-sample frame; + // this adds <= one hop of analysis scheduling, without changing + // the YIN arithmetic or allocating callback storage. + pendingMinimum = minFreq; pendingMaximum = maxFreq; pendingRmsDB = rmsDB; + pendingTauMin = juce::jmax(1, static_cast(sampleRate / maxFreq)); + pendingTauMax = juce::jmin(frameSize / 2 - 1, static_cast(sampleRate / minFreq)); + pendingSum = 0; yinBuffer[0] = 1; + if (pendingTauMax > pendingTauMin) pendingTau = 1; + else { detectedFreq.store(0); confidence.store(0); publishFrame(0, 0, rmsDB); } + } + else + { + const float freq = runYIN(frame.data(), frameSize); + publishFrame(freq, confidence.load(std::memory_order_relaxed), rmsDB); + } } } } @@ -120,6 +172,57 @@ float PitchDetector::runYIN(const float* frame, int size) : 0.0f; } + return finishYIN(tauMin, tauMax, minFreq, maxFreq); +} + +void PitchDetector::advanceYIN() noexcept +{ + if (pendingTau == 0) return; + const int maximum = juce::jmin(frameSize / 2 - 1, pendingTauMax + 1); + // Compute adjacent lags together, retaining each sum's original order. + // This exposes independent accumulators and reuses the first input load; + // publication still occurs on exactly the same retained input sample. + if (pendingTau < maximum) + { + float first = 0, second = 0; + const float* frame = analysisFrame.data(); + const float* shifted = frame + pendingTau; + for (int j = 0; j < frameSize / 2; ++j) + { + const float a = frame[j] - shifted[j]; + const float b = frame[j] - shifted[j + 1]; + first += a * a; + second += b * b; + } + pendingSum += first; + yinBuffer[static_cast(pendingTau)] = pendingSum > 0 ? first * static_cast(pendingTau) / pendingSum : 0; + ++pendingTau; + pendingSum += second; + yinBuffer[static_cast(pendingTau)] = pendingSum > 0 ? second * static_cast(pendingTau) / pendingSum : 0; + ++pendingTau; + } + else if (pendingTau == maximum) + { + float sum = 0; + for (int j = 0; j < frameSize / 2; ++j) + { + const float delta = analysisFrame[static_cast(j)] - analysisFrame[static_cast(j + pendingTau)]; + sum += delta * delta; + } + pendingSum += sum; + yinBuffer[static_cast(pendingTau)] = pendingSum > 0 ? sum * static_cast(pendingTau) / pendingSum : 0; + ++pendingTau; + } + if (pendingTau > maximum) + { + pendingTau = 0; + const float frequency = finishYIN(pendingTauMin, pendingTauMax, pendingMinimum, pendingMaximum); + publishFrame(frequency, confidence.load(std::memory_order_relaxed), pendingRmsDB); + } +} + +float PitchDetector::finishYIN(int tauMin, int tauMax, float minimum, float maximum) +{ // Step 3: Absolute threshold — find first dip below threshold int bestTau = -1; float bestVal = sensitivityThreshold; @@ -163,7 +266,7 @@ float PitchDetector::runYIN(const float* frame, int size) float freq = (refinedTau > 0.0f) ? static_cast(sampleRate) / refinedTau : 0.0f; // Clamp to valid range - if (freq < minFreq || freq > maxFreq) + if (freq < minimum || freq > maximum) { freq = 0.0f; conf = 0.0f; diff --git a/Source/PitchDetector.h b/Source/PitchDetector.h index 933a934..1b457f1 100644 --- a/Source/PitchDetector.h +++ b/Source/PitchDetector.h @@ -21,6 +21,8 @@ class PitchDetector public: PitchDetector(); + // Configuration only: call before prepare, never from an audio callback. + void setRateNormalized(bool enabled) noexcept { rateNormalized = enabled; } void prepare(double sampleRate, int maxBlockSize); // Feed audio samples; call this every processBlock. @@ -49,6 +51,9 @@ class PitchDetector private: double sampleRate = 44100.0; + bool rateNormalized = false; + int decimation = 1, decimationPosition = 0, antiAliasPosition = 0; + std::vector antiAliasCoefficients, antiAliasHistory; // Ring buffer for accumulating input samples std::vector inputBuffer; @@ -65,6 +70,10 @@ class PitchDetector // YIN internals std::vector yinBuffer; float runYIN(const float* frame, int size); + float finishYIN(int tauMin, int tauMax, float minimum, float maximum); + void advanceYIN() noexcept; + int pendingTau = 0, pendingTauMin = 0, pendingTauMax = 0; + float pendingSum = 0, pendingMinimum = 40, pendingMaximum = 1000, pendingRmsDB = -100; float parabolicInterpolation(int tauEstimate) const; // Results diff --git a/Source/PitchMapper.cpp b/Source/PitchMapper.cpp index 5857405..41edc22 100644 --- a/Source/PitchMapper.cpp +++ b/Source/PitchMapper.cpp @@ -41,10 +41,12 @@ void PitchMapper::reset() hasStableNote = hasSustainedCorrection = false; stableNote = pendingNote = sustainedCorrectionMidi = 0; stableNoteSeconds = pendingNoteSeconds = unvoicedSeconds = 0; + cachedLegacySpeed = cachedLegacyDelta = cachedSustainedSpeed = -1; + cachedSustainedDelta = -1; } -void PitchMapper::setKey(int note) { rootNote.store(note % 12, std::memory_order_relaxed); } -void PitchMapper::setScale(Scale scale) { currentScale.store(static_cast(scale), std::memory_order_relaxed); } +void PitchMapper::setKey(int note) { rootNote.store((note % 12 + 12) % 12, std::memory_order_relaxed); } +void PitchMapper::setScale(Scale scale) { currentScale.store(juce::jlimit(0, 15, static_cast(scale)), std::memory_order_relaxed); } void PitchMapper::setRetuneSpeed(float ms) { retuneSpeedMs.store(juce::jlimit(0.0f, 400.0f, ms), std::memory_order_relaxed); } void PitchMapper::setHumanize(float pct) { humanizePercent.store(juce::jlimit(0.0f, 100.0f, pct), std::memory_order_relaxed); } void PitchMapper::setHumanizeMode(HumanizeMode mode) @@ -58,7 +60,7 @@ void PitchMapper::setFormantShift(float st) { formantShiftSemitones.store(juce:: void PitchMapper::setNoteEnabled(int note, bool enabled) { - noteEnables[note % 12].store(enabled, std::memory_order_relaxed); + noteEnables[static_cast((note % 12 + 12) % 12)].store(enabled, std::memory_order_relaxed); } float PitchMapper::hzToMidi(float hz) @@ -89,9 +91,13 @@ float PitchMapper::snapToScale(float midiNote) const int candidate = nearest + offset; int degree = ((candidate - root) % 12 + 12) % 12; - // Check both scale membership AND per-note enable - if (scaleNotes[static_cast(degree)] - && noteEnables[static_cast(degree)].load(std::memory_order_relaxed)) + const bool allowed = correctedNoteMask.load(std::memory_order_relaxed) + ? (scale == Scale::Custom + ? noteEnables[static_cast((candidate % 12 + 12) % 12)].load(std::memory_order_relaxed) + : scaleNotes[static_cast(degree)]) + : (scaleNotes[static_cast(degree)] + && noteEnables[static_cast(degree)].load(std::memory_order_relaxed)); + if (allowed) { float dist = std::abs(midiNote - static_cast(candidate)); if (dist < bestDistance) @@ -167,10 +173,14 @@ float PitchMapper::mapPitch(float detectedHz, float conf, float deltaTime) { // Exponential smoothing toward target // Time constant: ~63% of the way there in speedMs milliseconds - float tau = speedMs / 1000.0f; // convert to seconds - float alpha = 1.0f - std::exp(-deltaTime / tau); - alpha = juce::jlimit(0.0f, 1.0f, alpha); - currentOutputMidi += (targetMidi - currentOutputMidi) * alpha; + if (speedMs != cachedLegacySpeed || deltaTime != cachedLegacyDelta) + { + const float tau = speedMs / 1000.0f; // convert to seconds + cachedLegacyAlpha = juce::jlimit(0.0f, 1.0f, 1.0f - std::exp(-deltaTime / tau)); + cachedLegacySpeed = speedMs; + cachedLegacyDelta = deltaTime; + } + currentOutputMidi += (targetMidi - currentOutputMidi) * cachedLegacyAlpha; } // 6. Scale confidence: reduce correction on uncertain frames @@ -248,8 +258,13 @@ float PitchMapper::mapSustainedPitch(float detectedHz, float conf, float deltaTi } else { - const float alpha = static_cast(1.0 - std::exp(-dt / (static_cast(speed) * .001))); - sustainedCorrectionMidi += (desiredCorrection - sustainedCorrectionMidi) * alpha; + if (speed != cachedSustainedSpeed || dt != cachedSustainedDelta) + { + cachedSustainedAlpha = static_cast(1.0 - std::exp(-dt / (static_cast(speed) * .001))); + cachedSustainedSpeed = speed; + cachedSustainedDelta = dt; + } + sustainedCorrectionMidi += (desiredCorrection - sustainedCorrectionMidi) * cachedSustainedAlpha; } const float confidenceGain = juce::jlimit(0.0f, 1.0f, conf * 2); // Humanize only changes corrective motion. It must not undo the requested diff --git a/Source/PitchMapper.h b/Source/PitchMapper.h index a8ef656..3b9fae8 100644 --- a/Source/PitchMapper.h +++ b/Source/PitchMapper.h @@ -39,6 +39,7 @@ class PitchMapper }; // ---- Parameters (all thread-safe) ---- + void setCorrectedNoteMask(bool enabled) { correctedNoteMask.store(enabled); } void setKey(int rootNote); // 0=C, 1=C#, ..., 11=B void setScale(Scale scale); void setRetuneSpeed(float ms); // 0=instant snap, 400=gentle @@ -57,7 +58,7 @@ class PitchMapper float getHumanize() const { return humanizePercent.load(std::memory_order_relaxed); } HumanizeMode getHumanizeMode() const { return static_cast(humanizeMode.load(std::memory_order_relaxed)); } int getTranspose() const { return transposeSemitones.load(std::memory_order_relaxed); } - bool isNoteEnabled(int note) const { return noteEnables[note % 12].load(std::memory_order_relaxed); } + bool isNoteEnabled(int note) const { return noteEnables[static_cast((note % 12 + 12) % 12)].load(std::memory_order_relaxed); } bool getFormantCorrection() const { return formantCorrectionOn.load(std::memory_order_relaxed); } float getFormantShift() const { return formantShiftSemitones.load(std::memory_order_relaxed); } float getCorrectionStrength() const { return correctionStrength.load(std::memory_order_relaxed); } @@ -91,6 +92,7 @@ class PitchMapper static const char* getScaleName(Scale scale); private: + std::atomic correctedNoteMask { false }; // Other callers retain historical mapping. std::atomic rootNote { 0 }; // 0=C std::atomic currentScale { 0 }; // Scale::Chromatic std::atomic retuneSpeedMs { 50.0f }; @@ -107,6 +109,11 @@ class PitchMapper float currentOutputMidi = 0.0f; bool hasLastOutput = false; double cachedSampleRate = 44100.0; + // Audio-thread-owned coefficient caches. Parameter and callback-size changes + // invalidate them by value, without changing either smoothing law. + float cachedLegacySpeed = -1, cachedLegacyDelta = -1, cachedLegacyAlpha = 0; + float cachedSustainedSpeed = -1, cachedSustainedAlpha = 0; + double cachedSustainedDelta = -1; // Callback-owned, allocation-free sustained-note tracking. These are // original timing heuristics, not a model of a commercial pitch corrector. diff --git a/Source/PlaybackEngine.cpp b/Source/PlaybackEngine.cpp index bf7ee5f..ffb1ba5 100644 --- a/Source/PlaybackEngine.cpp +++ b/Source/PlaybackEngine.cpp @@ -1889,7 +1889,8 @@ bool PlaybackEngine::hasPitchScrubPreview (const juce::String& clipId) const #pragma warning(push) #pragma warning(disable: 4244 4267 4305 4456) #endif -void PlaybackEngine::renderPitchScrubPreview (juce::AudioBuffer& buffer, double sampleRate) +void PlaybackEngine::renderPitchScrubPreview (juce::AudioBuffer& buffer, double sampleRate, + int activeSampleCount) { const juce::ScopedTryLock sl (lock); if (! sl.isLocked()) @@ -1910,7 +1911,11 @@ void PlaybackEngine::renderPitchScrubPreview (juce::AudioBuffer& buffer, const double readIncrement = playbackRatio; const int loopChannels = pitchScrubPreview.loopBuffer.getNumChannels(); const int outputChannels = buffer.getNumChannels(); - const int numSamples = buffer.getNumSamples(); + const int numSamples = activeSampleCount < 0 + ? buffer.getNumSamples() + : juce::jlimit(0, buffer.getNumSamples(), activeSampleCount); + if (numSamples == 0) + return; if (pitchScrubInputBuffer.getNumChannels() < loopChannels || pitchScrubInputBuffer.getNumSamples() < numSamples) { diff --git a/Source/PlaybackEngine.h b/Source/PlaybackEngine.h index 1a3da26..3c30612 100644 --- a/Source/PlaybackEngine.h +++ b/Source/PlaybackEngine.h @@ -235,7 +235,10 @@ class PlaybackEngine { return pitchScrubPreviewMayRender.load(std::memory_order_acquire); } - void renderPitchScrubPreview (juce::AudioBuffer& buffer, double sampleRate); + // A device callback may be shorter than its prepared scratch capacity. + // Passing its length avoids advancing the preview through unused samples. + void renderPitchScrubPreview (juce::AudioBuffer& buffer, double sampleRate, + int activeSampleCount = -1); 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 diff --git a/Source/ProjectFileStore.cpp b/Source/ProjectFileStore.cpp index 493925a..95d52a1 100644 --- a/Source/ProjectFileStore.cpp +++ b/Source/ProjectFileStore.cpp @@ -1,6 +1,8 @@ #include "ProjectFileStore.h" #include "JsonEnvelope.h" #include "RecoveryJournal.h" +#include +#include #include #include @@ -20,7 +22,7 @@ juce::Result checkedReplaceBytes(const juce::File& target, const void* bytes, si { juce::TemporaryFile temporary(target, juce::TemporaryFile::useHiddenFile); auto stream = temporary.getFile().createOutputStream(); - if (!stream) return juce::Result::fail("Cannot create temporary project file."); + if (!stream) return juce::Result::fail("Cannot create temporary document file."); // File::replaceWithText ignores appendText's return value in our JUCE pin. // Check the write AND flush before publishing, especially on a full disk. const bool written = stream->write(bytes, size); @@ -29,9 +31,17 @@ juce::Result checkedReplaceBytes(const juce::File& target, const void* bytes, si stream.reset(); if (!written || status.failed() || temporary.getFile().getSize() != static_cast(size)) - return juce::Result::fail("Project write/flush failed; the previous file was retained."); - if (!temporary.overwriteTargetFileWithTemporary()) - return juce::Result::fail("Project replacement failed; check the destination and recovery files."); + return juce::Result::fail("Document write/flush failed; the previous file was retained."); + #if JUCE_WINDOWS + const bool published = temporary.overwriteTargetFileWithTemporary(); + #else + // JUCE's POSIX move/replace falls back to copying after rename fails. + // Copying can truncate the destination before ultimately returning false. + // A same-directory rename must either publish atomically or leave it alone. + const bool published = ProjectFileStore::detail::renameTemporaryFile(temporary.getFile(), target); + #endif + if (!published) + return juce::Result::fail("Document replacement failed; the previous file was retained."); return juce::Result::ok(); } @@ -55,6 +65,32 @@ juce::File nextRecovery(const juce::File& target, int maxVersions) namespace ProjectFileStore { +#if !JUCE_WINDOWS +bool detail::renameTemporaryFile(const juce::File& temporary, const juce::File& target) +{ + if (temporary == target || temporary.getParentDirectory() != target.getParentDirectory() + || !temporary.existsAsFile() || target.isDirectory()) return false; + return ::rename(temporary.getFullPathName().toRawUTF8(), target.getFullPathName().toRawUTF8()) == 0; +} +#endif + +juce::Result saveJsonDocument(const juce::File& target, const juce::String& json) +{ + if (!hasBoundedJsonEnvelope(json, maximumJsonDocumentBytes)) + return juce::Result::fail("JSON document exceeds supported size or nesting, or is incomplete."); + const auto first = json.getCharPointer().findEndOfWhitespace(); + const auto* utf8 = json.toRawUTF8(); + // JUCE's JSON parser accepts trailing text and non-JSON string syntax. + // Validate the entire document without constructing another large DOM. + if ((*first != '{' && *first != '[') + || !nlohmann::json::accept(utf8, utf8 + json.getNumBytesAsUTF8())) + return juce::Result::fail("Invalid JSON document; no file was replaced."); + const std::lock_guard guard(publicationMutex); + if (!target.getParentDirectory().isDirectory() || target.isDirectory()) + return juce::Result::fail("JSON destination is not a writable file location."); + return checkedReplace(target, json); +} + juce::File RecoverySession::defaultRoot() { return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) diff --git a/Source/ProjectFileStore.h b/Source/ProjectFileStore.h index 1819a8a..8a71e84 100644 --- a/Source/ProjectFileStore.h +++ b/Source/ProjectFileStore.h @@ -3,6 +3,19 @@ namespace ProjectFileStore { + inline constexpr size_t maximumJsonDocumentBytes = 256u * 1024u * 1024u; + // Presets/metadata, without project schema or recovery side effects. + // Worker/control threads only; accepts one bounded JSON object or array. + juce::Result saveJsonDocument(const juce::File& target, const juce::String& json); + + #if !JUCE_WINDOWS + namespace detail + { + // Same-directory publication primitive; no copy fallback on failure. + bool renameTemporaryFile(const juce::File& temporary, const juce::File& target); + } + #endif + // Worker/control threads only. Checked temporary writes and serialized // replacement; recovery generations are ordinary loadable .osproj files. juce::Result save(const juce::File& target, const juce::String& json, diff --git a/Source/RealtimeTrackExecutor.cpp b/Source/RealtimeTrackExecutor.cpp new file mode 100644 index 0000000..852dd2c --- /dev/null +++ b/Source/RealtimeTrackExecutor.cpp @@ -0,0 +1,377 @@ +#include "RealtimeTrackExecutor.h" +#include +#include +#if JUCE_WINDOWS + #include + #include +#endif + +namespace +{ +void relaxExecutorPoll() noexcept +{ + #if JUCE_INTEL && JUCE_WINDOWS + YieldProcessor(); + #elif JUCE_INTEL + __builtin_ia32_pause(); + #else + std::atomic_signal_fence(std::memory_order_seq_cst); + #endif +} +} + +class RealtimeTrackExecutor::Worker final : public juce::Thread +{ +public: + Worker(RealtimeTrackExecutor& executor, int index) + : juce::Thread("Track audio worker " + juce::String(index)), owner(executor), workerIndex(index) {} + + void run() override + { + #if JUCE_WINDOWS + DWORD taskIndex = 0; + const HANDLE mmcss = AvSetMmThreadCharacteristicsW(L"Pro Audio", &taskIndex); + const bool prioritySet = mmcss != nullptr && AvSetMmThreadPriority(mmcss, AVRT_PRIORITY_HIGH) != FALSE; + if (mmcss != nullptr) owner.mmcssRegisteredWorkers.fetch_add(1, std::memory_order_relaxed); + if (prioritySet) owner.mmcssPriorityWorkers.fetch_add(1, std::memory_order_relaxed); + #endif + juce::ScopedNoDenormals noDenormals; + auto observedGeneration = owner.publishedGeneration.load(std::memory_order_seq_cst); + juce::int64 spinUntil = 0; + bool wokeFromEvent = false; + awake.store(true, std::memory_order_seq_cst); + started.signal(); + while (! threadShouldExit()) + { + const auto generation = owner.publishedGeneration.load(std::memory_order_seq_cst); + if (generation != observedGeneration) + { + observedGeneration = generation; + if (workerIndex >= owner.publishedActiveWorkers.load(std::memory_order_relaxed)) + { + // A smaller next batch must put surplus helpers back to + // sleep, not keep every previously active core polling. + spinUntil = 0; + continue; + } + if (!wokeFromEvent) owner.workerSpinBatches.fetch_add(1, std::memory_order_relaxed); + wokeFromEvent = false; + owner.processPublishedBatch(workerIndex); + const int spinMicroseconds = owner.getWorkerSpinMicroseconds(); + spinUntil = spinMicroseconds > 0 + ? juce::Time::getHighResolutionTicks() + + static_cast(spinMicroseconds) + * juce::Time::getHighResolutionTicksPerSecond() / 1000000 + : 0; + continue; + } + if (spinUntil != 0 && juce::Time::getHighResolutionTicks() < spinUntil) + { + relaxExecutorPoll(); + continue; + } + // Pair the sleep announcement with a generation recheck. The + // dispatcher either observes sleeping and signals, or this check + // observes publication before waiting. Sequential consistency + // prevents the store/load pair on each thread missing both changes. + awake.store(false, std::memory_order_seq_cst); + if (owner.publishedGeneration.load(std::memory_order_seq_cst) != observedGeneration) + { + awake.store(true, std::memory_order_seq_cst); + continue; + } + wake.wait(); + awake.store(true, std::memory_order_seq_cst); + if (threadShouldExit()) break; + owner.workerWakeups.fetch_add(1, std::memory_order_relaxed); + wokeFromEvent = true; + spinUntil = 0; + } + awake.store(false, std::memory_order_seq_cst); + #if JUCE_WINDOWS + if (prioritySet) owner.mmcssPriorityWorkers.fetch_sub(1, std::memory_order_relaxed); + if (mmcss != nullptr) + { + owner.mmcssRegisteredWorkers.fetch_sub(1, std::memory_order_relaxed); + AvRevertMmThreadCharacteristics(mmcss); + } + #endif + } + + bool wakeIfSleeping() noexcept + { + if (awake.load(std::memory_order_seq_cst)) return false; + wake.signal(); + return true; + } + + juce::WaitableEvent wake, started; +private: + RealtimeTrackExecutor& owner; + const int workerIndex; + std::atomic awake { false }; +}; + +RealtimeTrackExecutor::RealtimeTrackExecutor() +{ + juce::ignoreUnused(publicationPadding, claimPadding, completionPadding, readerPadding); +} +RealtimeTrackExecutor::~RealtimeTrackExecutor() { stop(); } + +int RealtimeTrackExecutor::recommendedWorkerCount() noexcept +{ + return juce::jlimit(0, maximumWorkerCount, juce::SystemStats::getNumPhysicalCpus() - 1); +} + +bool RealtimeTrackExecutor::prepare(int count) +{ + stop(); + if (count < 0) count = recommendedWorkerCount(); + if (count > maximumWorkerCount) return false; + workers.reserve(static_cast(count)); + for (int index = 0; index < count; ++index) + { + auto worker = std::make_unique(*this, index); + if (!worker->startThread(juce::Thread::Priority::highest)) + { + stop(); + return false; + } + workers.push_back(std::move(worker)); + if (!workers.back()->started.wait(5000)) + { + stop(); + return false; + } + } + configuredWorkers.store(count, std::memory_order_release); + accepting.store(true, std::memory_order_release); + return true; +} + +void RealtimeTrackExecutor::drain() +{ + while (!isIdle()) juce::Thread::sleep(1); +} + +void RealtimeTrackExecutor::stop() +{ + accepting.store(false, std::memory_order_release); + drain(); + for (auto& worker : workers) + { + worker->signalThreadShouldExit(); + worker->wake.signal(); + } + // Never force-kill a processor-owning worker. The host's jobs must be + // bounded; any fault-injection latch must be released before destruction. + for (auto& worker : workers) worker->waitForThreadToExit(-1); + workers.clear(); + configuredWorkers.store(0, std::memory_order_release); +} + +int RealtimeTrackExecutor::getWorkerCount() const noexcept +{ + return configuredWorkers.load(std::memory_order_acquire); +} + +bool RealtimeTrackExecutor::setWorkerSpinMicroseconds(int microseconds) noexcept +{ + if (microseconds < 0 || microseconds > maximumWorkerSpinMicroseconds) return false; + workerSpinMicroseconds.store(microseconds, std::memory_order_release); + return true; +} + +int RealtimeTrackExecutor::getWorkerSpinMicroseconds() const noexcept +{ + return workerSpinMicroseconds.load(std::memory_order_acquire); +} + +bool RealtimeTrackExecutor::isIdle() noexcept +{ + if (remainingJobs.load(std::memory_order_acquire) != 0) return false; + // Close admission before withdrawal. A worker delayed before entry cannot + // create a fresh empty reader epoch after retirement was acknowledged. + workerReaderGate.fetch_and(~1u, std::memory_order_acq_rel); + publishedJobs.store(nullptr, std::memory_order_release); + return workerReaderGate.load(std::memory_order_acquire) == 0; +} + +bool RealtimeTrackExecutor::executeNext(Job* jobs, bool worker) noexcept +{ + const int count = publishedCount.load(std::memory_order_relaxed); + // Once the queue is exhausted, collection must only read completion. + // Repeated fetch_add here used to contend with finishing workers for the + // entire remaining deadline while no further job could possibly run. + if (nextJob.load(std::memory_order_relaxed) >= count) return false; + if (juce::Time::getHighResolutionTicks() >= deadline.load(std::memory_order_relaxed)) return false; + const int index = nextJob.fetch_add(1, std::memory_order_relaxed); + // A concurrent claimant can consume the last job after the fast check. + if (index >= count) return false; + auto& job = jobs[index]; + auto expected = State::ready; + if (!job.state.compare_exchange_strong(expected, State::running, std::memory_order_acq_rel)) return true; + job.run(job.context); + // run() has returned, so all job-scoped guards/destructors have completed. + // No subsequent code dereferences context or invokes the processor. + job.state.store(State::done, std::memory_order_release); + (worker ? workerJobs : callbackJobs).fetch_add(1, std::memory_order_relaxed); + remainingJobs.fetch_sub(1, std::memory_order_release); + return true; +} + +void RealtimeTrackExecutor::processPublishedBatch(int workerIndex) noexcept +{ + auto gate = workerReaderGate.load(std::memory_order_acquire); + do + { + if ((gate & 1u) == 0) return; + } + while (!workerReaderGate.compare_exchange_weak(gate, gate + 2u, + std::memory_order_acquire, std::memory_order_relaxed)); + // A worker may have been descheduled before entering the gate, across + // retirement and a newer publication. Recheck this batch's helper limit + // while pinned, so surplus helpers cannot claim a smaller batch's jobs. + if (workerIndex < publishedActiveWorkers.load(std::memory_order_relaxed)) + if (auto* jobs = publishedJobs.load(std::memory_order_acquire)) + while (executeNext(jobs, true)) {} + // This is terminal with respect to batch storage, including Ready scans. + workerReaderGate.fetch_sub(2u, std::memory_order_release); +} + +void RealtimeTrackExecutor::cancelUnclaimed(Job* jobs, int count) noexcept +{ + for (int index = 0; index < count; ++index) + { + auto expected = State::ready; + if (jobs[index].state.compare_exchange_strong(expected, State::cancelled, std::memory_order_acq_rel)) + { + cancelledJobs.fetch_add(1, std::memory_order_relaxed); + remainingJobs.fetch_sub(1, std::memory_order_release); + } + } +} + +bool RealtimeTrackExecutor::runBatch(Job* jobs, int count, juce::int64 cutoffTicks, + juce::int64 hardCutoffTicks) noexcept +{ + if (hardCutoffTicks == 0) hardCutoffTicks = cutoffTicks; + const auto maximumGraceTicks = juce::Time::getHighResolutionTicksPerSecond() + * maximumLateCompletionMicroseconds / 1000000; + const auto maximumTick = (std::numeric_limits::max)(); + const auto maximumHardCutoff = cutoffTicks > maximumTick - maximumGraceTicks + ? maximumTick : cutoffTicks + maximumGraceTicks; + if (!accepting.load(std::memory_order_acquire) || !isIdle() + || count < 0 || count > maximumJobCount || (count > 0 && jobs == nullptr) + || hardCutoffTicks < cutoffTicks || hardCutoffTicks > maximumHardCutoff) + { + rejectedBatches.fetch_add(1, std::memory_order_relaxed); + return false; + } + for (int index = 0; index < count; ++index) + if (jobs[index].run == nullptr) + { + rejectedBatches.fetch_add(1, std::memory_order_relaxed); + return false; + } + if (count == 0) return true; + for (int index = 0; index < count; ++index) jobs[index].state.store(State::ready, std::memory_order_relaxed); + publishedCount.store(count, std::memory_order_relaxed); + nextJob.store(0, std::memory_order_relaxed); + deadline.store(hardCutoffTicks, std::memory_order_relaxed); + const int wakeCount = juce::jmin(getWorkerCount(), count - 1); + publishedActiveWorkers.store(wakeCount, std::memory_order_relaxed); + remainingJobs.store(count, std::memory_order_release); + publishedJobs.store(jobs, std::memory_order_release); + workerReaderGate.store(1u, std::memory_order_release); + publishedGeneration.fetch_add(1, std::memory_order_seq_cst); + batches.fetch_add(1, std::memory_order_relaxed); + for (int index = 0; index < wakeCount; ++index) + if (workers[static_cast(index)]->wakeIfSleeping()) + workerEventSignals.fetch_add(1, std::memory_order_relaxed); + juce::ScopedNoDenormals noDenormals; + bool nominalMissRecorded = false; + const auto recordNominalMiss = [&] (juce::int64 time) noexcept + { + if (!nominalMissRecorded && time >= cutoffTicks) + { + nominalMissRecorded = true; + deadlineMisses.fetch_add(1, std::memory_order_relaxed); + } + }; + for (;;) + { + const auto now = juce::Time::getHighResolutionTicks(); + recordNominalMiss(now); + if (now >= hardCutoffTicks) break; + if (remainingJobs.load(std::memory_order_acquire) == 0 && isIdle()) + { + const auto completedAt = juce::Time::getHighResolutionTicks(); + recordNominalMiss(completedAt); + if (completedAt < hardCutoffTicks) + { + (nominalMissRecorded ? completedLateBatches : completedBatches) + .fetch_add(1, std::memory_order_relaxed); + return !nominalMissRecorded; + } + break; + } + if (!executeNext(jobs, false)) relaxExecutorPoll(); + } + cancelUnclaimed(jobs, count); + hardDeadlineMisses.fetch_add(1, std::memory_order_relaxed); + // Withdraw completed batches now, but never imply ownership of running jobs. + (void) isIdle(); + return false; +} + +RealtimeTrackExecutor::Stats RealtimeTrackExecutor::snapshotStats() const noexcept +{ + Stats result; + result.batches = batches.load(std::memory_order_relaxed); + result.completedBatches = completedBatches.load(std::memory_order_relaxed); + result.deadlineMisses = deadlineMisses.load(std::memory_order_relaxed); + result.completedLateBatches = completedLateBatches.load(std::memory_order_relaxed); + result.hardDeadlineMisses = hardDeadlineMisses.load(std::memory_order_relaxed); + result.rejectedBatches = rejectedBatches.load(std::memory_order_relaxed); + result.callbackJobs = callbackJobs.load(std::memory_order_relaxed); + result.workerJobs = workerJobs.load(std::memory_order_relaxed); + result.cancelledJobs = cancelledJobs.load(std::memory_order_relaxed); + result.workerWakeups = workerWakeups.load(std::memory_order_relaxed); + result.workerSpinBatches = workerSpinBatches.load(std::memory_order_relaxed); + result.workerEventSignals = workerEventSignals.load(std::memory_order_relaxed); + result.workerSpinMicroseconds = getWorkerSpinMicroseconds(); + result.claimCursor = nextJob.load(std::memory_order_relaxed); + result.publishedJobCount = publishedCount.load(std::memory_order_relaxed); + result.workers = getWorkerCount(); + result.mmcssRegisteredWorkers = mmcssRegisteredWorkers.load(std::memory_order_relaxed); + result.mmcssPriorityWorkers = mmcssPriorityWorkers.load(std::memory_order_relaxed); + return result; +} + +juce::var RealtimeTrackExecutor::getDiagnosticState() const +{ + const auto stats = snapshotStats(); + auto* result = new juce::DynamicObject(); + result->setProperty("workers", stats.workers); + result->setProperty("mmcssRegisteredWorkers", stats.mmcssRegisteredWorkers); + result->setProperty("mmcssPriorityWorkers", stats.mmcssPriorityWorkers); + result->setProperty("batches", static_cast(stats.batches)); + result->setProperty("completedBatches", static_cast(stats.completedBatches)); + result->setProperty("deadlineMisses", static_cast(stats.deadlineMisses)); + result->setProperty("completedLateBatches", static_cast(stats.completedLateBatches)); + result->setProperty("hardDeadlineMisses", static_cast(stats.hardDeadlineMisses)); + result->setProperty("rejectedBatches", static_cast(stats.rejectedBatches)); + result->setProperty("callbackJobs", static_cast(stats.callbackJobs)); + result->setProperty("workerJobs", static_cast(stats.workerJobs)); + result->setProperty("cancelledJobs", static_cast(stats.cancelledJobs)); + result->setProperty("workerWakeups", static_cast(stats.workerWakeups)); + result->setProperty("workerSpinBatches", static_cast(stats.workerSpinBatches)); + result->setProperty("workerEventSignals", static_cast(stats.workerEventSignals)); + result->setProperty("workerSpinMicroseconds", stats.workerSpinMicroseconds); + result->setProperty("claimCursor", stats.claimCursor); + result->setProperty("publishedJobCount", stats.publishedJobCount); + result->setProperty("wakeStrategy", stats.workerSpinMicroseconds > 0 + ? "bounded-active-poll-then-event" : "persistent-auto-reset-event"); + return result; +} diff --git a/Source/RealtimeTrackExecutor.h b/Source/RealtimeTrackExecutor.h new file mode 100644 index 0000000..006311d --- /dev/null +++ b/Source/RealtimeTrackExecutor.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// A same-block executor, not an audio graph. The caller owns every job/context +// until isIdle() returns true, including after a missed deadline. Control-side +// prepare/drain/stop must be serialised against runBatch by the host. +class RealtimeTrackExecutor final +{ +public: + enum class State : unsigned char { idle, ready, running, done, cancelled }; + struct Job + { + void* context = nullptr; + void (*run)(void*) noexcept = nullptr; + std::atomic state { State::idle }; + }; + struct Stats + { + std::uint64_t batches = 0, completedBatches = 0, deadlineMisses = 0; + std::uint64_t completedLateBatches = 0, hardDeadlineMisses = 0; + std::uint64_t rejectedBatches = 0, callbackJobs = 0, workerJobs = 0; + std::uint64_t cancelledJobs = 0, workerWakeups = 0; + std::uint64_t workerSpinBatches = 0, workerEventSignals = 0; + int workers = 0, mmcssRegisteredWorkers = 0, mmcssPriorityWorkers = 0; + int workerSpinMicroseconds = 0; + int claimCursor = 0, publishedJobCount = 0; + }; + + static constexpr int maximumWorkerCount = 127; + static constexpr int maximumJobCount = 4096; + static constexpr int maximumWorkerSpinMicroseconds = 2000; + static constexpr int maximumLateCompletionMicroseconds = 2000; + RealtimeTrackExecutor(); + ~RealtimeTrackExecutor(); + RealtimeTrackExecutor(const RealtimeTrackExecutor&) = delete; + RealtimeTrackExecutor& operator=(const RealtimeTrackExecutor&) = delete; + + static int recommendedWorkerCount() noexcept; + bool prepare(int workerCount = -1); // Control thread; zero selects serial execution. + void drain(); // Control thread; host must stop dispatch first. + void stop(); // Control thread; drains, then joins workers. + int getWorkerCount() const noexcept; + // Opt-in bounded polling between active batches. Zero retains event sleep. + // Configure on the control thread; this setting survives prepare/stop. + bool setWorkerSpinMicroseconds(int microseconds) noexcept; + int getWorkerSpinMicroseconds() const noexcept; + + // cutoffTicks is an absolute juce::Time::getHighResolutionTicks() deadline, + // with the caller's output/reduction reserve already subtracted. The wait + // is bounded, but a claimed run() must itself finish: native DSP cannot be + // preempted safely, including the job performed by the callback thread. + // Optional recovery may continue up to hardCutoffTicks (at most 2ms beyond + // nominal). Zero uses cutoffTicks. Late completion still returns false and + // records a nominal deadline miss; this never increases promised capacity. + bool runBatch(Job* jobs, int count, juce::int64 cutoffTicks, + juce::int64 hardCutoffTicks = 0) noexcept; + // Nonblocking acknowledgement. True also proves no worker is still + // searching this batch or touching its job/context after run() returned. + bool isIdle() noexcept; + Stats snapshotStats() const noexcept; + juce::var getDiagnosticState() const; // Allocates; control/UI thread only. + +private: + class Worker; + void processPublishedBatch(int workerIndex) noexcept; + bool executeNext(Job* jobs, bool worker) noexcept; + void cancelUnclaimed(Job* jobs, int count) noexcept; + + std::vector> workers; + std::atomic configuredWorkers { 0 }; + std::atomic accepting { false }; + // Separate independently written groups by at least a common cache line, + // without imposing over-alignment/padding requirements on AudioEngine. + // This is an optimisation only; ordering does not rely on line size. + std::atomic publishedJobs { nullptr }; + std::atomic publishedGeneration { 0 }; + std::atomic publishedActiveWorkers { 0 }; + std::atomic workerSpinMicroseconds { 0 }; + std::atomic publishedCount { 0 }; + std::atomic deadline { 0 }; + std::array publicationPadding {}; + std::atomic nextJob { 0 }; + std::array claimPadding {}; + std::atomic remainingJobs { 0 }; + std::array completionPadding {}; + // Low bit admits worker readers; each active reader contributes two. + // Closing admission makes retirement acknowledgement stable until publish. + std::atomic workerReaderGate { 0 }; + std::array readerPadding {}; + std::atomic batches { 0 }, completedBatches { 0 }, deadlineMisses { 0 }; + std::atomic completedLateBatches { 0 }, hardDeadlineMisses { 0 }; + std::atomic rejectedBatches { 0 }, callbackJobs { 0 }, workerJobs { 0 }; + std::atomic cancelledJobs { 0 }, workerWakeups { 0 }; + std::atomic workerSpinBatches { 0 }, workerEventSignals { 0 }; + std::atomic mmcssRegisteredWorkers { 0 }, mmcssPriorityWorkers { 0 }; +}; diff --git a/Source/RealtimeTrackExecutorRegression.h b/Source/RealtimeTrackExecutorRegression.h new file mode 100644 index 0000000..4fc8460 --- /dev/null +++ b/Source/RealtimeTrackExecutorRegression.h @@ -0,0 +1,230 @@ +#pragma once +#include "RealtimeTrackExecutor.h" +#include +#include + +// Explicit offline executor contracts. No audio device, app graph, settings, +// model files or physical input/output are involved. +inline juce::var runRealtimeTrackExecutorRegression() +{ + juce::Array checks; + bool passed = true; + const auto check = [&] (const char* name, bool success, const char* detail) + { + auto* result = new juce::DynamicObject(); + result->setProperty("name", name); + result->setProperty("pass", success); + result->setProperty("detail", detail); + checks.add(result); + passed = passed && success; + }; + const auto cutoffAfter = [] (double seconds) + { + return juce::Time::getHighResolutionTicks() + + static_cast(seconds * static_cast(juce::Time::getHighResolutionTicksPerSecond())); + }; + struct CountContext + { + std::atomic calls { 0 }, active { 0 }; + std::atomic overlap { false }; + std::uint64_t output = 0; + static void process(void* pointer) noexcept + { + auto& context = *static_cast(pointer); + if (context.active.fetch_add(1) != 0) context.overlap.store(true); + std::uint64_t value = 17; + for (int index = 0; index < 400; ++index) value = value * 1664525u + 1013904223u; + context.output = value; + context.calls.fetch_add(1); + context.active.fetch_sub(1); + } + }; + constexpr int maximumJobs = 31; + std::array contexts; + std::array jobs; + bool exactlyOnce = true, outputPublished = true, idleAfterReturn = true; + juce::Array configurations; + for (const int spinMicroseconds : { 0, 1000 }) + for (const int workerCount : { 0, 1, 3 }) + { + RealtimeTrackExecutor executor; + const bool prepared = executor.setWorkerSpinMicroseconds(spinMicroseconds) + && executor.prepare(workerCount); + exactlyOnce = exactlyOnce && prepared && executor.getWorkerCount() == workerCount; + if (!prepared) continue; + for (const int count : { 1, 2, 4, 16, maximumJobs }) + for (int repeat = 0; repeat < 16; ++repeat) + { + // Exercise both consecutive publications and the transition + // back to event sleep after bounded polling has expired. + if (repeat == 8) juce::Thread::sleep(5); + for (int index = 0; index < count; ++index) + { + contexts[static_cast(index)].calls.store(0); + contexts[static_cast(index)].output = 0; + jobs[static_cast(index)].context = &contexts[static_cast(index)]; + jobs[static_cast(index)].run = CountContext::process; + } + const bool completed = executor.runBatch(jobs.data(), count, cutoffAfter(1)); + executor.drain(); + exactlyOnce = exactlyOnce && completed; + idleAfterReturn = idleAfterReturn && executor.isIdle(); + for (int index = 0; index < count; ++index) + { + const auto& context = contexts[static_cast(index)]; + exactlyOnce = exactlyOnce && context.calls.load() == 1 + && context.active.load() == 0 && !context.overlap.load() + && jobs[static_cast(index)].state.load() == RealtimeTrackExecutor::State::done; + outputPublished = outputPublished && context.output != 0 + && context.output == contexts[0].output; + } + } + configurations.add(executor.getDiagnosticState()); + executor.stop(); + idleAfterReturn = idleAfterReturn && executor.isIdle() && executor.getWorkerCount() == 0; + } + check("exactly_once_serial_parallel", exactlyOnce, "0/1/3 workers with event-only and 1000us bounded polling; 1/2/4/16/31 jobs across 16 batch reuses including sleep/wake transitions; no duplicate invocation."); + check("output_and_completion_publication", outputPublished && idleAfterReturn, "Successful batches publish complete output and return ownership only after worker readers drain."); + + RealtimeTrackExecutor serial; + const bool spinConfiguration = serial.getWorkerSpinMicroseconds() == 0 + && !serial.setWorkerSpinMicroseconds(-1) + && !serial.setWorkerSpinMicroseconds(RealtimeTrackExecutor::maximumWorkerSpinMicroseconds + 1) + && serial.setWorkerSpinMicroseconds(RealtimeTrackExecutor::maximumWorkerSpinMicroseconds) + && serial.getWorkerSpinMicroseconds() == RealtimeTrackExecutor::maximumWorkerSpinMicroseconds + && serial.setWorkerSpinMicroseconds(0); + check("bounded_poll_configuration", spinConfiguration, + "Event sleep is the default; only 0..2000us bounded active polling is accepted."); + bool prepared = serial.prepare(0); + std::array cancelled; + CountContext cancelledContext; + for (auto& job : cancelled) { job.context = &cancelledContext; job.run = CountContext::process; } + const bool timeout = !serial.runBatch(cancelled.data(), static_cast(cancelled.size()), cutoffAfter(-1)); + bool allCancelled = true; + for (const auto& job : cancelled) allCancelled = allCancelled && job.state.load() == RealtimeTrackExecutor::State::cancelled; + check("expired_batch_never_runs", prepared && timeout && allCancelled && serial.isIdle() && cancelledContext.calls.load() == 0, + "An already-expired batch cancels every unclaimed job without invoking a processor."); + RealtimeTrackExecutor::Job invalid; + const bool invalidRejected = !serial.runBatch(&invalid, 1, cutoffAfter(1)) + && invalid.state.load() == RealtimeTrackExecutor::State::idle + && !serial.runBatch(&invalid, RealtimeTrackExecutor::maximumJobCount + 1, cutoffAfter(1)); + check("invalid_and_empty_batches", invalidRejected && serial.runBatch(nullptr, 0, cutoffAfter(1)) && serial.isIdle(), + "Invalid job functions are rejected before publication; empty batches are valid no-ops."); + + RealtimeTrackExecutor graceExecutor; + const bool gracePrepared = graceExecutor.prepare(0); + CountContext graceContext; + RealtimeTrackExecutor::Job graceJob; + graceJob.context = &graceContext; + graceJob.run = CountContext::process; + const auto nominalCutoff = juce::Time::getHighResolutionTicks() - 1; + const auto graceTicks = juce::Time::getHighResolutionTicksPerSecond() + * RealtimeTrackExecutor::maximumLateCompletionMicroseconds / 1000000; + const bool graceReportedLate = !graceExecutor.runBatch(&graceJob, 1, nominalCutoff, nominalCutoff + graceTicks); + const auto graceStats = graceExecutor.snapshotStats(); + check("bounded_late_completion_is_still_a_miss", gracePrepared && graceReportedLate + && graceContext.calls.load() == 1 && graceJob.state.load() == RealtimeTrackExecutor::State::done + && graceExecutor.isIdle() && graceStats.deadlineMisses == 1 && graceStats.completedLateBatches == 1 + && graceStats.completedBatches == 0 && graceStats.hardDeadlineMisses == 0 && graceStats.cancelledJobs == 0, + "Explicit bounded recovery can finish an already-late job, while return value and nominal deadline diagnostics still report failure."); + const auto validNominal = cutoffAfter(1); + const bool invalidGraceRejected = !graceExecutor.runBatch(&graceJob, 1, validNominal, validNominal - 1) + && !graceExecutor.runBatch(&graceJob, 1, validNominal, validNominal + graceTicks + 1) + && graceExecutor.snapshotStats().batches == 1 && graceContext.calls.load() == 1; + check("late_completion_limit_rejects_invalid_cutoffs", invalidGraceRejected, + "A hard cutoff before nominal or more than 2000us after nominal is rejected before publishing jobs."); + + struct LateContext + { + std::thread::id callbackThread; + std::atomic entered { false }, release { false }; + std::atomic workerGuards { 0 }, workerGuardDestructions { 0 }, calls { 0 }; + juce::int64 coordinationLimit = 0; + static void process(void* pointer) noexcept + { + auto& context = *static_cast(pointer); + context.calls.fetch_add(1); + if (std::this_thread::get_id() == context.callbackThread) + { + // Fault fixture only: let the persistent helper claim its job + // before the callback finishes all remaining cheap jobs. + while (!context.entered.load() && juce::Time::getHighResolutionTicks() < context.coordinationLimit) + std::this_thread::yield(); + return; + } + struct Guard + { + LateContext& context; + explicit Guard(LateContext& c) : context(c) { context.workerGuards.fetch_add(1); } + ~Guard() { context.workerGuardDestructions.fetch_add(1); context.workerGuards.fetch_sub(1); } + } guard(context); + context.entered.store(true); + while (!context.release.load()) juce::Thread::sleep(1); + } + }; + bool lateOwnershipPassed = true, drainPassed = true, exhaustedQueuePassed = true, hardCutoffPassed = true; + juce::Array lateWorkerStats; + for (const int spinMicroseconds : { 0, 1000 }) + for (const int recoveryMicroseconds : { 0, 1000 }) + { + LateContext late; + RealtimeTrackExecutor delayed; + prepared = delayed.setWorkerSpinMicroseconds(spinMicroseconds) && delayed.prepare(1); + late.callbackThread = std::this_thread::get_id(); + late.coordinationLimit = cutoffAfter(.25); + std::array delayedJobs; + for (auto& job : delayedJobs) { job.context = ⪭ job.run = LateContext::process; } + const auto delayedNominal = cutoffAfter(.05); + const auto delayedHardCutoff = recoveryMicroseconds == 0 ? 0 : delayedNominal + + juce::Time::getHighResolutionTicksPerSecond() * recoveryMicroseconds / 1000000; + const bool delayedMiss = !delayed.runBatch(delayedJobs.data(), static_cast(delayedJobs.size()), + delayedNominal, delayedHardCutoff); + const auto delayedStats = delayed.snapshotStats(); + hardCutoffPassed = hardCutoffPassed && delayedStats.deadlineMisses == 1 + && delayedStats.hardDeadlineMisses == 1 && delayedStats.completedLateBatches == 0; + exhaustedQueuePassed = exhaustedQueuePassed + && delayedStats.claimCursor <= static_cast(delayedJobs.size()) + delayed.getWorkerCount(); + RealtimeTrackExecutor::Job rejected; + CountContext rejectedContext; + rejected.context = &rejectedContext; rejected.run = CountContext::process; + const bool busyRejected = late.entered.load() && !delayed.isIdle() + && !delayed.runBatch(&rejected, 1, cutoffAfter(1)) + && rejected.state.load() == RealtimeTrackExecutor::State::idle && rejectedContext.calls.load() == 0; + const bool runningGuardRetained = late.workerGuards.load() == 1 && late.workerGuardDestructions.load() == 0; + std::atomic drainFinished { false }; + std::thread drainer([&] { delayed.drain(); drainFinished.store(true); }); + juce::Thread::sleep(5); + const bool drainWaited = !drainFinished.load(); + late.release.store(true); + drainer.join(); + const bool terminalCompletion = delayed.isIdle() && late.workerGuards.load() == 0 + && late.workerGuardDestructions.load() == 1 && drainFinished.load(); + const bool reusable = delayed.runBatch(&rejected, 1, cutoffAfter(1)) && rejectedContext.calls.load() == 1 && delayed.isIdle(); + lateOwnershipPassed = lateOwnershipPassed && prepared && delayedMiss && busyRejected && runningGuardRetained; + drainPassed = drainPassed && drainWaited && terminalCompletion && reusable; + lateWorkerStats.add(delayed.getDiagnosticState()); + } + check("late_worker_retains_ownership", lateOwnershipPassed, + "In both wake modes, a claimed late job cannot be cancelled or have its storage reused by a new batch."); + check("control_drain_waits_for_guard_destruction", drainPassed, + "In both wake modes, control-side drain waits for late worker/guard completion before the next batch becomes legal."); + check("exhausted_queue_stops_claiming", exhaustedQueuePassed, + "Waiting for a deliberately late job does not keep advancing the exhausted claim cursor; at most one racing claim per helper is allowed."); + check("hard_cutoff_does_not_wait_for_blocked_worker", hardCutoffPassed && lateOwnershipPassed, + "Default and explicit recovery cutoffs return while the fault-injected worker remains blocked, retain ownership and record both nominal and hard misses."); + const auto expectedRecommended = juce::jlimit(0, RealtimeTrackExecutor::maximumWorkerCount, + juce::SystemStats::getNumPhysicalCpus() - 1); + check("physical_core_scaling", RealtimeTrackExecutor::recommendedWorkerCount() == expectedRecommended, + "Default helper count equals physical cores minus one; no CPU affinity is asserted. Explicit zero and three-worker configurations are independently covered."); + auto* result = new juce::DynamicObject(); + result->setProperty("pass", passed); + result->setProperty("success", passed); + result->setProperty("overallPass", passed); + result->setProperty("checks", checks); + result->setProperty("configurations", configurations); + result->setProperty("lateWorkerStats", lateWorkerStats); + result->setProperty("recommendedWorkers", expectedRecommended); + result->setProperty("scope", "Offline executor ownership/ordering contracts only; native audio capacity and audible quality are not asserted."); + result->setProperty("audioQuality", "not_asserted"); + return result; +} diff --git a/Source/RecordingRecoveryRegression.cpp b/Source/RecordingRecoveryRegression.cpp index c541e85..5f0bfd4 100644 --- a/Source/RecordingRecoveryRegression.cpp +++ b/Source/RecordingRecoveryRegression.cpp @@ -5,6 +5,9 @@ #include "OwnedChildProcess.h" #include "ProjectFileStore.h" #include "ExpectedWavFailureLog.h" +#include +#include +#include namespace { @@ -131,6 +134,209 @@ int runInterruptedRecordingFixture(const juce::File& root) void runRecordingRecoveryRegression(const juce::File& directory, const std::function& check) { + // No device or user preferences: native recorder, synthetic audio and an + // isolated journal. The bounded total fits each writer FIFO even if disk + // scheduling pauses, so a preview-induced lost frame cannot be hidden. + const auto previewRoot = directory.getChildFile("recording-preview"); + previewRoot.createDirectory(); + for (const int blockSize : { 8, 16, 64 }) + { + const auto blockRoot = previewRoot.getChildFile("blocks-" + juce::String(blockSize)); + const auto checkBlock = [&](const char* name, bool passed) { + const auto qualifiedName = juce::String(name) + "_" + juce::String(blockSize) + "_samples"; + check(qualifiedName.toRawUTF8(), passed); + }; + AudioRecorder recorder(blockRoot.getChildFile("journals")); + const auto monoFile = blockRoot.getChildFile("mono.wav"); + const auto stereoFile = blockRoot.getChildFile("stereo.wav"); + const bool started = recorder.startRecording("mono", monoFile, 48000.0, 1) + && recorder.startRecording("stereo", stereoFile, 48000.0, 2); + checkBlock("recording_preview_concurrent_fixture_started", started); + if (started) + { + juce::AudioBuffer mono(1, blockSize), stereo(2, blockSize); + for (int sample = 0; sample < blockSize; ++sample) + { + mono.setSample(0, sample, 0.125f); + stereo.setSample(0, sample, 0.125f); stereo.setSample(1, sample, -0.25f); + } + const auto write = [&] { + recorder.writeBlock("mono", mono, blockSize); + recorder.writeBlock("stereo", stereo, blockSize); + }; + for (int block = 0; block < 4; ++block) write(); + juce::WaitableEvent written; + bool independent = false; + std::thread writer; + { + // Deliberately stall every non-realtime preview/status lock. + // Audio writes must finish before these locks are released. + const juce::ScopedLock previewLock(recorder.previewRegistryLock); + const juce::ScopedLock statusLock(recorder.statusLock); + const juce::ScopedLock growthLock(recorder.recordingPreviews.at("mono")->growthLock); + writer = std::thread([&] { for (int block = 0; block < 64; ++block) write(); written.signal(); }); + independent = written.wait(2000); + } + writer.join(); + checkBlock("recording_writes_ignore_preview_status_growth_locks", independent); + std::atomic done { false }, previewValid { true }; + std::atomic polls { 0 }; + juce::WaitableEvent polling; + std::thread reader([&] { + polling.signal(); + while (!done.load(std::memory_order_acquire)) + { + for (const auto& id : { "mono", "stereo" }) + { + const auto peaks = recorder.getRecordingPeaks(id, 256, 2048); + if (const auto* values = peaks.getArray(); values != nullptr && !values->isEmpty()) + { + const int channels = static_cast((*values)[0]); + if (channels != (juce::String(id) == "mono" ? 1 : 2) + || (values->size() - 1) % (channels * 2) != 0) previewValid = false; + } + } + if (recorder.takeWriteFailures().size() != 0) previewValid = false; + recorder.maintainRecordingPreviews(); + polls.fetch_add(1, std::memory_order_release); + std::this_thread::yield(); + } + }); + polling.wait(2000); + for (int block = 0; block < 512; ++block) + { + write(); + if (block % 16 == 0) juce::Thread::sleep(1); + } + done.store(true, std::memory_order_release); reader.join(); + const auto completed = recorder.stopAllRecordings(48000.0); + const int expectedFrames = (4 + 64 + 512) * blockSize; + bool exact = completed.size() == 2 && previewValid && polls.load() > 0 + && recorder.getWriteLockMissCount() == 0 && recorder.getWriterBufferOverflowCount() == 0; + for (const auto& file : { monoFile, stereoFile }) + { + juce::WavAudioFormat format; + std::unique_ptr audio(format.createReaderFor(new juce::FileInputStream(file), true)); + const int channels = file == monoFile ? 1 : 2; + juce::AudioBuffer samples(channels, expectedFrames); + const bool readable = audio && audio->lengthInSamples == expectedFrames + && audio->read(&samples, 0, expectedFrames, 0, true, true); + exact = readable && exact; + if (readable) for (int channel = 0; channel < channels; ++channel) + for (int sample = 0; sample < expectedFrames; ++sample) + exact = exact && samples.getSample(channel, sample) == (channel == 0 ? 0.125f : -0.25f); + } + checkBlock("recording_preview_status_poll_preserves_every_pcm_frame", exact); + } + } + { + AudioRecorder recorder(previewRoot.getChildFile("boundary-journals")); + const auto firstFile = previewRoot.getChildFile("boundary.wav"); + const bool started = recorder.startRecording("boundary", firstFile, 48000.0, 1); + check("recording_preview_boundary_fixture_started", started); + if (started) + { + juce::AudioBuffer block(1, 256); + block.clear(); block.setSample(0, 0, 0.125f); + recorder.writeBlock("boundary", block, 256); + const auto first = recorder.getRecordingPeaks("boundary", 256, 1); + block.clear(); block.setSample(0, 0, 0.75f); + recorder.writeBlock("boundary", block, 255); + const auto partial = recorder.getRecordingPeaks("boundary", 256, 2); + block.clear(); recorder.writeBlock("boundary", block, 1); + const auto repeated = recorder.getRecordingPeaks("boundary", 256, 1); + const auto appended = recorder.getRecordingPeaks("boundary", 256, 1, 256); + check("recording_preview_complete_bucket_append_matches_refetch", + first.size() == 3 && partial.size() == 3 && repeated.size() == 3 && appended.size() == 3 + && static_cast(first[2]) == 0.125f && static_cast(repeated[2]) == 0.125f + && static_cast(appended[2]) == 0.75f); + auto retained = recorder.recordingPreviews.at("boundary"); + std::atomic done { false }, valid { true }; + std::thread reader([&] { + while (!done.load(std::memory_order_acquire)) + { + const auto peaks = recorder.getRecordingPeaks("boundary", 256, 8192); + if (const auto* values = peaks.getArray(); values != nullptr && !values->isEmpty()) + if (static_cast((*values)[0]) != 1 || values->size() > 5) valid = false; + } + }); + AudioRecorder::CompletedRecording previous; + const bool rolled = recorder.rolloverRecording("boundary", previewRoot.getChildFile("rolled.wav"), 48000.0, 1, 4.0, previous); + block.clear(); block.setSample(0, 0, -0.5f); recorder.writeBlock("boundary", block, 256); + const auto rolledPeaks = recorder.getRecordingPeaks("boundary", 256, 1); + recorder.stopRecording("boundary"); + done.store(true, std::memory_order_release); reader.join(); + check("recording_preview_stop_rollover_retains_readers_and_resets_take", + rolled && valid && previous.duration == 512.0 / 48000.0 + && !retained->active.load() && retained->peakChunks[0][1] == 0.125f + && rolledPeaks.size() == 3 && static_cast(rolledPeaks[1]) == -0.5f + && recorder.getRecordingPeaks("boundary", 256, 1).size() == 0); + } + } + { + AudioRecorder recorder(previewRoot.getChildFile("gap-journals")); + const bool started = recorder.startRecording("gap", previewRoot.getChildFile("gap.wav"), 48000.0, 1); + check("recording_preview_gap_fixture_started", started); + if (started) + { + // Advance only the preview clock to the first storage boundary. + // Real writes still exercise the callback's missing-storage path. + const auto preview = recorder.recordingPreviews.at("gap"); + const auto boundary = preview->peakChunkEntries; + preview->peakTableSize.store(boundary, std::memory_order_release); + juce::AudioBuffer block(1, 256); + block.clear(); block.setSample(0, 0, 0.5f); + recorder.writeBlock("gap", block, 256); + const bool clockAdvanced = preview->peakTableSize.load() == boundary + 1; + recorder.maintainRecordingPreviews(); + block.clear(); block.setSample(0, 0, 0.75f); + recorder.writeBlock("gap", block, 256); + const auto peaks = recorder.getRecordingPeaks("gap", 256, 2, + static_cast(boundary) * AudioRecorder::recordingPeakStride); + check("recording_preview_delayed_growth_keeps_zero_gap_and_sample_time", + clockAdvanced && peaks.size() == 5 + && static_cast(peaks[1]) == 0.0f && static_cast(peaks[2]) == 0.0f + && static_cast(peaks[4]) == 0.75f); + const auto maximum = boundary * AudioRecorder::RecordingPreview::PEAK_MAX_CHUNKS; + preview->peakTableSize.store(maximum, std::memory_order_release); + recorder.writeBlock("gap", block, 256); + const auto completed = recorder.stopAllRecordings(48000.0); + check("recording_preview_capacity_saturates_without_stopping_pcm", + preview->peakTableSize.load() == maximum && completed.size() == 1 + && completed.front().duration == 768.0 / 48000.0 + && recorder.getWriterBufferOverflowCount() == 0); + } + } + { + // Virtual published bucket history avoids writing hundreds of seconds + // of PCM merely to exercise visibility-independent maintenance/bounds. + AudioRecorder recorder(previewRoot.getChildFile("bounds-journals")); + auto preview = std::make_shared(384000.0, 2); + recorder.recordingPreviews["virtual"] = preview; + preview->peakTableSize.store(preview->peakChunkEntries); + recorder.maintainRecordingPreviews(); // No waveform request preceded this. + const bool grew = preview->peakAllocatedChunks.load() == 2; + for (size_t chunk = 0; chunk < 2; ++chunk) + if (preview->peakChunks[chunk]) + for (size_t entry = 0; entry < preview->peakChunkEntries; ++entry) + { + auto* values = preview->peakChunks[chunk].get() + entry * 4; + values[0] = -0.125f; values[1] = 0.25f; values[2] = -0.5f; values[3] = 0.75f; + } + preview->peakTableSize.store(preview->peakChunkEntries * 2, std::memory_order_release); + const auto pixels = recorder.getRecordingPeaks("virtual", 256, std::numeric_limits::max()); + const auto work = recorder.getRecordingPeaks("virtual", 65536, std::numeric_limits::max()); + const auto legacy = recorder.getRecordingPeaks("virtual", 64, 1); + const auto invalid = recorder.getRecordingPeaks("virtual", 0, 100); + const auto distant = recorder.getRecordingPeaks("virtual", 256, 10, std::numeric_limits::max()); + check("recording_preview_maintenance_grows_without_visible_requests", grew); + check("recording_preview_payload_work_and_integer_bounds", + pixels.size() == 1 + AudioRecorder::maximumRecordingPeakPixels * 4 + && work.size() == 1 + static_cast(AudioRecorder::maximumRecordingPeakEntriesPerRequest / 256) * 4 + && legacy.size() == 5 && static_cast(legacy[2]) == 0.25f + && invalid.size() == 0 && distant.size() == 0); + } + checkFinalizationRecovery(directory, check); const auto root = directory.getChildFile("recording-interruption"); root.createDirectory(); diff --git a/Source/ReverbFactoryLibraryRegression.inc b/Source/ReverbFactoryLibraryRegression.inc new file mode 100644 index 0000000..b8b5f4f --- /dev/null +++ b/Source/ReverbFactoryLibraryRegression.inc @@ -0,0 +1,284 @@ + { + bool listed = true, data = true, recall = true, copies = true, protectedEntries = true; + bool collectionBrowse = true, diskPriority = true; + int generatedCount = 0; + const auto pluginName = juce::String("OpenStudio Reverb"); + const auto presets = getBuiltInFXPresets(pluginName); + const auto factoryTrackId = addTrack("reverb-factory-regression-" + juce::Uuid().toString()); + recall = factoryTrackId.isNotEmpty() && addTrackBuiltInFX(factoryTrackId, pluginName); + const auto factoryTrackEntry = trackMap.find(factoryTrackId); + auto* factoryReverb = factoryTrackEntry != trackMap.end() + ? dynamic_cast(factoryTrackEntry->second->getTrackFXProcessor(0)) : nullptr; + for (const auto& voice : OpenStudioReverbFactoryPresets::voices) + { + bool found = false, generated = false; + if (const auto* array = presets.getArray()) + for (const auto& preset : *array) + if (preset["name"].toString().equalsIgnoreCase(voice.name)) + { found = true; generated = static_cast(preset["factory"]); } + listed = found && listed; + // A pre-existing same-name disk preset has deliberate priority. Never + // overwrite/delete it in this regression; generated entry checks apply + // only where the new factory entry is actually visible. + if (! generated) + continue; + ++generatedCount; + juce::MemoryBlock expected, actual; + OpenStudioReverbFactoryPresets::buildState(voice.name, expected); + const auto expectedTree = juce::ValueTree::readFromData(expected.getData(), expected.getSize()); + data = static_cast(expectedTree["reverbReturnMode"]) == 1 && data; + const auto encoded = getBuiltInFXPresetData(pluginName, voice.name); + data = actual.fromBase64Encoding(encoded) && actual == expected && data; + const auto suffix = voice.kind == OpenStudioReverbFactoryPresets::Kind::Collection ? juce::String("Voiced-collection") + : (voice.kind == OpenStudioReverbFactoryPresets::Kind::Individual ? "Voiced-" : "") + juce::String(voice.type); + const auto file = outputDirectory.getChildFile("ReverbFactory-" + suffix + ".ospreset"); + auto* fileRequest = new juce::DynamicObject(); + fileRequest->setProperty("pluginName", pluginName); + fileRequest->setProperty("path", file.getFullPathName()); + fileRequest->setProperty("fullState", encoded); + fileRequest->setProperty("overwrite", true); + const juce::var request(fileRequest); + const auto exported = eqPresetLibrary("export", request); + const auto imported = eqPresetLibrary("import", request); + data = static_cast(exported["success"]) && static_cast(imported["success"]) + && imported["state"]["fullState"].toString() == encoded && data; + if (recall) + { + recall = setBuiltInPluginParam(factoryTrackId, "track", 0, "mixLock", 0) + && loadBuiltInFXPreset(factoryTrackId, "track", 0, voice.name) && recall; + const auto state = getBuiltInPluginState(factoryTrackId, "track", 0); + juce::MemoryBlock recalled; + recall = recalled.fromBase64Encoding(state["fullState"].toString()) && recalled == expected && recall; + if (voice.kind == OpenStudioReverbFactoryPresets::Kind::Collection) + { + collectionBrowse = factoryReverb != nullptr && collectionBrowse; + for (const auto& profile : OpenStudioReverbFactoryPresets::profiles) + { + collectionBrowse = setBuiltInPluginParam(factoryTrackId, "track", 0, "reverbTypeAll", static_cast(profile.type)) && collectionBrowse; + if (factoryReverb != nullptr) + for (size_t field = 0; field < profile.controls.size(); ++field) + collectionBrowse = factoryReverb->getBankValue(profile.type, static_cast(field)) == profile.controls[field] && collectionBrowse; + } + collectionBrowse = setBuiltInPluginParam(factoryTrackId, "track", 0, "reverbTypeAll", 0) && collectionBrowse; + const auto browsed = getBuiltInPluginState(factoryTrackId, "track", 0); + juce::MemoryBlock browsedBytes; + collectionBrowse = browsedBytes.fromBase64Encoding(browsed["fullState"].toString()) && browsedBytes == expected && collectionBrowse; + } + recall = setBuiltInPluginParam(factoryTrackId, "track", 0, "wetLevel", .17f) + && setBuiltInPluginParam(factoryTrackId, "track", 0, "dryLevel", .81f) + && setBuiltInPluginParam(factoryTrackId, "track", 0, "mixLock", 1) + && setBuiltInPluginParam(factoryTrackId, "track", 0, "outputTrimDb", -11) + && loadBuiltInFXPreset(factoryTrackId, "track", 0, voice.name) && recall; + const auto locked = getBuiltInPluginState(factoryTrackId, "track", 0); + juce::MemoryBlock lockedBytes; + const bool decoded = lockedBytes.fromBase64Encoding(locked["fullState"].toString()); + const auto tree = juce::ValueTree::readFromData(lockedBytes.getData(), lockedBytes.getSize()); + recall = decoded && static_cast(tree["wetLevel"]) == .17f + && static_cast(tree["dryLevel"]) == .81f && static_cast(tree["mixLock"]) == 1 + && static_cast(tree["outputTrimDb"]) == voice.outputTrimDb + && static_cast(tree["reverbReturnMode"]) == 1 && recall; + } + const auto copyName = "reverb-factory-copy-" + juce::Uuid().toString(); + const bool copied = copyBuiltInFXPreset(pluginName, voice.name, copyName); + juce::MemoryBlock copiedBytes; + copies = copied && copiedBytes.fromBase64Encoding(getBuiltInFXPresetData(pluginName, copyName)) + && copiedBytes == expected && copies; + bool metadataCopied = false; + const auto copiedPresets = getBuiltInFXPresets(pluginName); + if (const auto* array = copiedPresets.getArray()) + for (const auto& preset : *array) + if (preset["name"].toString() == copyName) + metadataCopied = preset["metadata"]["character"].toString() == voice.character + && ! static_cast(preset["factory"]); + copies = metadataCopied && copies; + if (copied) copies = deleteBuiltInFXPreset(pluginName, copyName) && copies; + protectedEntries = ! saveBuiltInFXPresetData(pluginName, voice.name, encoded) + && ! copyBuiltInFXPreset(pluginName, voice.name, voice.name) + && ! deleteBuiltInFXPreset(pluginName, voice.name) && protectedEntries; + if (factoryTrackId.isNotEmpty()) + protectedEntries = ! saveBuiltInFXPreset(factoryTrackId, "track", 0, voice.name) && protectedEntries; + } + // A reserved generated display name can already exist in a user's old + // library. Exercise that precedence in an isolated override, without + // creating/replacing a file in the real user preset directories. + { + // Atomic preset writes create temporary siblings. Keep this test-only + // path short enough for Windows even under a nested report root. + const auto isolatedRoot = outputDirectory.getChildFile("rv-" + juce::Uuid().toString().substring(0,12)); + const auto persistentRoot = isolatedRoot.getChildFile("p"); + const auto legacyRoot = isolatedRoot.getChildFile("l"); + const juce::ScopedValueSetter persistentScope(builtInPresetPersistentRootOverride, persistentRoot); + const juce::ScopedValueSetter legacyScope(builtInPresetLegacyRootOverride, legacyRoot); + const auto directory = getPersistentBuiltInPresetsDir(pluginName); + const auto collisionName = juce::String(OpenStudioReverbFactoryPresets::collection.name); + const auto collisionFile = directory.getChildFile(collisionName + ".ospreset"); + juce::MemoryBlock userState, readBack; + diskPriority = OpenStudioReverbFactoryPresets::buildState(OpenStudioReverbFactoryPresets::existingVoices[0].name, userState) + && directory.createDirectory().wasOk() && writeBuiltInPresetPayloadAtomically(collisionFile, userState) && diskPriority; + bool userListed = false; + const auto precedencePresets = getBuiltInFXPresets(pluginName); + if (const auto* array = precedencePresets.getArray()) + for (const auto& preset : *array) + if (preset["name"].toString() == collisionName) userListed = !static_cast(preset["factory"]); + diskPriority = userListed && readBack.fromBase64Encoding(getBuiltInFXPresetData(pluginName, collisionName)) + && readBack == userState && diskPriority; + diskPriority = setBuiltInPluginParam(factoryTrackId, "track", 0, "mixLock", 0) + && loadBuiltInFXPreset(factoryTrackId, "track", 0, collisionName) && diskPriority; + const auto recalled = getBuiltInPluginState(factoryTrackId, "track", 0); + const bool priorityDecoded=readBack.fromBase64Encoding(recalled["fullState"].toString()); + auto* priorityEvidence=new juce::DynamicObject(); + priorityEvidence->setProperty("beforeRecallComparison",diskPriority); + priorityEvidence->setProperty("decoded",priorityDecoded); + priorityEvidence->setProperty("userListed",userListed); + priorityEvidence->setProperty("sameState",readBack==userState); + juce::Array differences; + const auto expectedPriority=juce::ValueTree::readFromData(userState.getData(),userState.getSize()); + const auto actualPriority=juce::ValueTree::readFromData(readBack.getData(),readBack.getSize()); + for(int property=0;propertysetProperty("property",name.toString()); + difference->setProperty("expected",expectedPriority[name].toString().substring(0,120)); + difference->setProperty("actual",actualPriority[name].toString().substring(0,120));differences.add(difference); + } + priorityEvidence->setProperty("differences",differences); + outputDirectory.getChildFile("reverb-preset-priority-evidence.json").replaceWithText(juce::JSON::toString(juce::var(priorityEvidence))); + diskPriority = priorityDecoded && readBack == userState && diskPriority; + diskPriority = collisionFile.deleteFile() && diskPriority; + (void) directory.deleteFile(); (void) persistentRoot.deleteFile(); + (void) legacyRoot.deleteFile(); (void) isolatedRoot.deleteFile(); + } + bool refinementMigration = setBuiltInPluginParam(factoryTrackId, "track", 0, "reverbCharacterMode", 1); + const auto refinementState = getBuiltInPluginState(factoryTrackId, "track", 0); + refinementMigration = setBuiltInPluginState(factoryTrackId, "track", 0, "{\"factoryDefault\":true}") && refinementMigration; + juce::MemoryBlock refinementBytes; + refinementMigration = refinementBytes.fromBase64Encoding(refinementState["fullState"].toString()) && refinementMigration; + auto refinementTree = juce::ValueTree::readFromData(refinementBytes.getData(), refinementBytes.getSize()); + refinementMigration = factoryReverb != nullptr && factoryReverb->reverbCharacterMode.load() == 0 && refinementMigration; + refinementMigration = setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(refinementState)) + && factoryReverb != nullptr && factoryReverb->reverbCharacterMode.load() == 1 && refinementMigration; + refinementTree.removeProperty("reverbCharacterMode", nullptr); + juce::MemoryBlock preRefinementBytes; + { juce::MemoryOutputStream stream(preRefinementBytes, false); refinementTree.writeToStream(stream); } + auto* preRefinementObject = new juce::DynamicObject(); + preRefinementObject->setProperty("name", pluginName); + preRefinementObject->setProperty("fullState", preRefinementBytes.toBase64Encoding()); + const auto migrationFile = outputDirectory.getChildFile("Reverb-Original-state.ospreset"); + auto* migrationRequest = new juce::DynamicObject(); + migrationRequest->setProperty("pluginName", pluginName); migrationRequest->setProperty("path", migrationFile.getFullPathName()); + migrationRequest->setProperty("fullState", preRefinementBytes.toBase64Encoding()); migrationRequest->setProperty("overwrite", true); + const juce::var migrationFileRequest(migrationRequest); + const auto migrationExport = eqPresetLibrary("export", migrationFileRequest); + const auto migrationImport = eqPresetLibrary("import", migrationFileRequest); + refinementMigration = static_cast(migrationExport["success"]) && static_cast(migrationImport["success"]) + && setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(juce::var(preRefinementObject))) + && factoryReverb != nullptr && factoryReverb->reverbCharacterMode.load() == 0 && refinementMigration; + + bool returnMigration = factoryReverb != nullptr + && setBuiltInPluginParam(factoryTrackId, "track", 0, "reverbReturnMode", 0); + const auto returnState = getBuiltInPluginState(factoryTrackId, "track", 0); + returnMigration = setBuiltInPluginState(factoryTrackId, "track", 0, "{\"factoryDefault\":true}") + && factoryReverb != nullptr && factoryReverb->reverbReturnMode.load() == 1 && returnMigration; + // This is the native complete-state JSON payload used by project FX + // recall. It exercises JSON/base64 boundaries, not a frontend file UI. + const auto projectPayload = juce::JSON::parse(juce::JSON::toString(returnState)); + returnMigration = setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(projectPayload)) + && factoryReverb != nullptr && factoryReverb->reverbReturnMode.load() == 0 && returnMigration; + juce::MemoryBlock returnBytes; + returnMigration = returnBytes.fromBase64Encoding(returnState["fullState"].toString()) && returnMigration; + auto returnTree = juce::ValueTree::readFromData(returnBytes.getData(), returnBytes.getSize()); + returnTree.removeProperty("reverbReturnMode", nullptr); + juce::MemoryBlock preReturnBytes; + { juce::MemoryOutputStream stream(preReturnBytes, false); returnTree.writeToStream(stream); } + const auto returnFile = outputDirectory.getChildFile("Reverb-Original-return-state.ospreset"); + auto* returnRequestObject = new juce::DynamicObject(); + returnRequestObject->setProperty("pluginName", pluginName); returnRequestObject->setProperty("path", returnFile.getFullPathName()); + returnRequestObject->setProperty("fullState", preReturnBytes.toBase64Encoding()); returnRequestObject->setProperty("overwrite", true); + const juce::var returnRequest(returnRequestObject); + const auto returnExport = eqPresetLibrary("export", returnRequest); + const auto returnImport = eqPresetLibrary("import", returnRequest); + returnMigration = static_cast(returnExport["success"]) && static_cast(returnImport["success"]) + && setBuiltInPluginParam(factoryTrackId, "track", 0, "reverbReturnMode", 1) + && setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(returnImport["state"])) + && factoryReverb != nullptr && factoryReverb->reverbReturnMode.load() == 0 && returnMigration; + addCheck("reverb_return_state_migration", returnMigration, + "Original return level recalls through project FX JSON and portable preset payloads; missing old state restores Original, while factory reset and new factory voices use Calibrated."); + bool controlMigration = factoryReverb != nullptr + && setBuiltInPluginParam(factoryTrackId, "track", 0, "reverbControlMode", 0); + const auto controlsState = getBuiltInPluginState(factoryTrackId, "track", 0); + controlMigration = setBuiltInPluginState(factoryTrackId, "track", 0, "{\"factoryDefault\":true}") + && factoryReverb->reverbControlMode.load() == 1 && controlMigration; + controlMigration = setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(controlsState)) + && factoryReverb->reverbControlMode.load() == 0 && controlMigration; + juce::MemoryBlock controlsBytes; controlsBytes.fromBase64Encoding(controlsState["fullState"].toString()); + auto controlsTree = juce::ValueTree::readFromData(controlsBytes.getData(), controlsBytes.getSize()); + controlsTree.removeProperty("reverbControlMode", nullptr); + juce::MemoryBlock oldControlsBytes; + { juce::MemoryOutputStream stream(oldControlsBytes, false); controlsTree.writeToStream(stream); } + const auto controlsFile = outputDirectory.getChildFile("Reverb-Legacy-controls.ospreset"); + auto* controlsRequestObject = new juce::DynamicObject(); + controlsRequestObject->setProperty("pluginName", pluginName); controlsRequestObject->setProperty("path", controlsFile.getFullPathName()); + controlsRequestObject->setProperty("fullState", oldControlsBytes.toBase64Encoding()); controlsRequestObject->setProperty("overwrite", true); + const juce::var controlsRequest(controlsRequestObject); + const auto controlsExport = eqPresetLibrary("export", controlsRequest); + const auto controlsImport = eqPresetLibrary("import", controlsRequest); + controlMigration = static_cast(controlsExport["success"]) && static_cast(controlsImport["success"]) + && setBuiltInPluginParam(factoryTrackId, "track", 0, "reverbControlMode", 1) + && setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(controlsImport["state"])) + && factoryReverb->reverbControlMode.load() == 0 && controlMigration; + addCheck("reverb_control_behavior_migration", controlMigration, + "Project/Compare full state retains the control behavior; missing fields in portable presets load Legacy, while factory reset uses Corrected."); + bool optInMigration = factoryReverb != nullptr + && setBuiltInPluginParam(factoryTrackId, "track", 0, "shimmerScheduling", 1) + && setBuiltInPluginParam(factoryTrackId, "track", 0, "ambBody", .63f); + const auto optInState = getBuiltInPluginState(factoryTrackId, "track", 0); + optInMigration = setBuiltInPluginState(factoryTrackId, "track", 0, "{\"factoryDefault\":true}") + && factoryReverb != nullptr && factoryReverb->shimmerScheduling.load() == 0 + && factoryReverb->ambBody.load() == 0 && optInMigration; + optInMigration = setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(optInState)) + && factoryReverb != nullptr && factoryReverb->shimmerScheduling.load() == 1 + && factoryReverb->ambBody.load() == .63f && optInMigration; + juce::MemoryBlock optInBytes; + optInMigration = optInBytes.fromBase64Encoding(optInState["fullState"].toString()) && optInMigration; + auto* optInRequestObject = new juce::DynamicObject(); + optInRequestObject->setProperty("pluginName", pluginName); + optInRequestObject->setProperty("path", outputDirectory.getChildFile("Reverb-Spread-body-state.ospreset").getFullPathName()); + optInRequestObject->setProperty("fullState", optInBytes.toBase64Encoding()); optInRequestObject->setProperty("overwrite", true); + const juce::var optInRequest(optInRequestObject); + const auto optInExport = eqPresetLibrary("export", optInRequest); + const auto optInImport = eqPresetLibrary("import", optInRequest); + optInMigration = static_cast(optInExport["success"]) && static_cast(optInImport["success"]) + && setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(optInImport["state"])) + && factoryReverb != nullptr && factoryReverb->shimmerScheduling.load() == 1 + && factoryReverb->ambBody.load() == .63f && optInMigration; + auto preOptInTree = juce::ValueTree::readFromData(optInBytes.getData(), optInBytes.getSize()); + preOptInTree.removeProperty("shimmerScheduling", nullptr); preOptInTree.removeProperty("ambBody", nullptr); + juce::MemoryBlock preOptInBytes; + { juce::MemoryOutputStream stream(preOptInBytes, false); preOptInTree.writeToStream(stream); } + auto* preOptInObject = new juce::DynamicObject(); + preOptInObject->setProperty("name", pluginName); preOptInObject->setProperty("fullState", preOptInBytes.toBase64Encoding()); + optInMigration = setBuiltInPluginState(factoryTrackId, "track", 0, juce::JSON::toString(juce::var(preOptInObject))) + && factoryReverb != nullptr && factoryReverb->shimmerScheduling.load() == 0 + && factoryReverb->ambBody.load() == 0 && optInMigration; + addCheck("reverb_spread_body_state_migration", optInMigration, + "Spread scheduling and Vowel broadband body round-trip through complete host/project and portable state; reset and missing historical fields restore Original/zero."); + const bool factoryTrackRemoved = factoryTrackId.isNotEmpty() && removeTrack(factoryTrackId); + addCheck("reverb_refinement_state_migration", refinementMigration, + "Refined round-trips in host state; factory reset and old portable state without the new property restore Original."); + addCheck("reverb_factory_library_list", listed, + "Eight stable voices, 41 type-specific authored voices and one collection appear in the preset API; existing same-name disk presets retain priority."); + addCheck("reverb_factory_library_data", data, + "Generated voices provide complete portable native state, including output trim."); + addCheck("reverb_factory_library_recall", recall, + "Native factory recall preserves full state and the existing six-field Mix Lock; output trim follows the selected voice."); + addCheck("reverb_factory_library_copy", copies, + "Factory voices duplicate into independent user preset files with identical state and descriptive metadata, without live recall."); + addCheck("reverb_factory_library_immutable", protectedEntries && factoryTrackRemoved, + "Generated entries reject overwrite, replacement and deletion; regression-created user copies and the temporary track are removed. Tested " + + juce::String(generatedCount) + " generated entries; same-name disk entries are left untouched."); + addCheck("reverb_factory_collection_browse", collectionBrowse, + "Collection recall stores all 41 authored banks; native selector browsing and returning to Room preserves the complete state."); + addCheck("reverb_factory_existing_disk_priority", diskPriority, + "An isolated historical disk preset with the collection's display name wins listing, data and recall; real user directories remain untouched."); + } diff --git a/Source/RuntimeSafetyRegression.cpp b/Source/RuntimeSafetyRegression.cpp index aeec8e4..bf9b173 100644 --- a/Source/RuntimeSafetyRegression.cpp +++ b/Source/RuntimeSafetyRegression.cpp @@ -24,15 +24,20 @@ #include "PeakCache.h" #include "JSFXProcessor.h" #include "AppUpdater.h" +#include "ARAHostController.h" #include #include #include #include +#include #if JUCE_WINDOWS #ifndef NOMINMAX #define NOMINMAX #endif #include +#else +#include +#include #endif namespace @@ -132,6 +137,9 @@ int RuntimeSafetyRegression::run(const juce::File& directory) if (!directory.createDirectory() || directory.getChildFile("result.json").exists()) return 2; juce::Array checks; bool passed = true; +#if !JUCE_WINDOWS + juce::String posixPublicationFailureStatus = "not_asserted: root can bypass directory write permissions"; +#endif const auto check = [&] (const char* name, bool success) { auto result = std::make_unique(); @@ -142,6 +150,38 @@ int RuntimeSafetyRegression::run(const juce::File& directory) }; juce::MidiBuffer midi; AudioDeviceConfigurationRegression::run(check); +#if OpenStudio_HAS_ARA + { + ARAHostController::OpenStudioContentAccessController content; + const auto tempo = content.createMusicalContextContentReader(nullptr, ARA::kARAContentTypeTempoEntries, nullptr); + const auto bars = content.createMusicalContextContentReader(nullptr, ARA::kARAContentTypeBarSignatures, nullptr); + check("ara_content_handles_preserve_full_pointer_width", + reinterpret_cast(tempo) == static_cast(ARA::kARAContentTypeTempoEntries) + && reinterpret_cast(bars) == static_cast(ARA::kARAContentTypeBarSignatures) + && content.getContentReaderEventCount(tempo) == 2 && content.getContentReaderEventCount(bars) == 2); + for (int index = 0; index < 2; ++index) + { + const auto* event = static_cast(content.getContentReaderDataForEvent(tempo, index)); + check(index == 0 ? "ara_tempo_reader_first_event" : "ara_tempo_reader_second_event", + event != nullptr && event->timePosition == index * 0.5 && event->quarterPosition == index); + const auto* signature = static_cast(content.getContentReaderDataForEvent(bars, index)); + check(index == 0 ? "ara_bar_reader_first_event" : "ara_bar_reader_second_event", + signature != nullptr && signature->position == index * 4.0 + && signature->numerator == 4 && signature->denominator == 4); + } + check("ara_content_reader_rejects_unsupported_content", + content.createMusicalContextContentReader(nullptr, ARA::kARAContentTypeNotes, nullptr) == nullptr + && content.getContentReaderEventCount(nullptr) == 0 + && content.getContentReaderDataForEvent(nullptr, 0) == nullptr); + check("ara_content_reader_rejects_invalid_event_indices", + content.getContentReaderDataForEvent(tempo, -1) == nullptr + && content.getContentReaderDataForEvent(tempo, 2) == nullptr + && content.getContentReaderDataForEvent(bars, -1) == nullptr + && content.getContentReaderDataForEvent(bars, 2) == nullptr); + content.destroyContentReader(tempo); + content.destroyContentReader(bars); + } +#endif #if JUCE_LINUX check("linux_https_transport_enabled_for_managed_ai_downloads", JUCE_USE_CURL != 0); check("failed_gpu_probe_output_is_not_hardware_evidence", @@ -897,6 +937,77 @@ int RuntimeSafetyRegression::run(const juce::File& directory) const bool retained = target.loadFileAsString() == second; const bool writableAgain = target.setReadOnly(false); check("failed_project_publication_retains_previous_bytes", rejected && retained && writableAgain); +#endif + } + { + const auto target = directory.getChildFile("preset.ospreset"); + const auto first = juce::String::fromUTF8("{\"type\":\"NAM Rack\",\"name\":\"Clair \xc3\xa9t\xc3\xa9 \xf0\x9f\x8e\xb8\"," + "\"values\":{\"gain\":0.25},\"path\":\"C:\\\\models\\\\clean.nam\"}"); + const juce::String second = R"json([{"name":"Pitch","scale":7,"scaleMask":2741,"signalPath":1}])json"; + check("json_document_object_roundtrip", ProjectFileStore::saveJsonDocument(target, first).wasOk() + && target.loadFileAsString() == first + && juce::JSON::parse(target.loadFileAsString())["values"]["gain"] == juce::var(0.25)); + check("json_document_array_roundtrip", ProjectFileStore::saveJsonDocument(target, second).wasOk() + && target.loadFileAsString() == second && juce::JSON::parse(target.loadFileAsString()).isArray()); + bool malformedRejected = true; + for (const auto* invalid : { "{truncated", "{} trailing", "{} {}", "{\"a\":1,}", + "[1,]", "{\"name\":'single quotes'}", "{\"a\":NaN}", + "null", "42", "\"text\"", "" }) + malformedRejected = ProjectFileStore::saveJsonDocument(target, invalid).failed() + && target.loadFileAsString() == second && malformedRejected; + check("json_document_malformed_preserves_previous_bytes", malformedRejected); + const auto deep = juce::String::repeatedString("[", 65) + "0" + juce::String::repeatedString("]", 65); + check("json_document_nesting_limit_preserves_previous_bytes", ProjectFileStore::saveJsonDocument(target, deep).failed() + && target.loadFileAsString() == second); + { + // Valid JSON beyond the production UTF-8 limit. Bulk construction + // avoids hundreds of millions of tiny repeatedString calls in Debug. + std::vector bytes(ProjectFileStore::maximumJsonDocumentBytes + 1u, ' '); + bytes[0] = '['; bytes[1] = ']'; + const juce::String oversized(juce::CharPointer_UTF8(bytes.data()), + juce::CharPointer_UTF8(bytes.data() + bytes.size())); + check("json_document_size_limit_preserves_previous_bytes", ProjectFileStore::saveJsonDocument(target, oversized).failed() + && target.loadFileAsString() == second); + } + const auto invalidDirectory = directory.getChildFile("json-document-directory"); + invalidDirectory.createDirectory(); + check("json_document_invalid_destination_preserves_existing_file", ProjectFileStore::saveJsonDocument(invalidDirectory, first).failed() + && ProjectFileStore::saveJsonDocument(directory.getChildFile("missing-parent/preset.json"), first).failed() + && invalidDirectory.isDirectory() && target.loadFileAsString() == second); + check("json_document_has_no_project_recovery_side_effects", directory.findChildFiles(juce::File::findFiles, + false, "preset.ospreset.recovery-*.osproj").isEmpty()); + check("project_schema_remains_strict_for_presets", ProjectFileStore::save(target, first, false, 3).failed() + && target.loadFileAsString() == second); +#if JUCE_WINDOWS + const bool readOnly = target.setReadOnly(true); + const bool rejected = readOnly && ProjectFileStore::saveJsonDocument(target, first).failed(); + const bool retained = target.loadFileAsString() == second; + const bool writableAgain = target.setReadOnly(false); + check("json_document_failed_publication_preserves_previous_bytes", rejected && retained && writableAgain); +#else + if (::geteuid() != 0) + { + const auto publicationDirectory = directory.getChildFile("json-publication-denied"); + const auto previousFile = publicationDirectory.getChildFile("previous.json"); + const auto temporaryFile = publicationDirectory.getChildFile("pending.tmp"); + struct stat originalMode {}; + const bool prepared = publicationDirectory.createDirectory().wasOk() + && previousFile.replaceWithText(first) && temporaryFile.replaceWithText(second) + && ::stat(publicationDirectory.getFullPathName().toRawUTF8(), &originalMode) == 0; + const auto path = publicationDirectory.getFullPathName(); + const juce::ScopeGuard restorePermissions {[&] { + if (prepared) (void) ::chmod(path.toRawUTF8(), originalMode.st_mode & 07777); + }}; + const bool denied = prepared && ::chmod(path.toRawUTF8(), 0500) == 0; + // Both files remain writable. Only rename/unlink is denied; JUCE's + // old copy fallback would overwrite previousFile in this scenario. + const bool rejected = denied && !ProjectFileStore::detail::renameTemporaryFile(temporaryFile, previousFile); + const bool retained = previousFile.loadFileAsString() == first && temporaryFile.loadFileAsString() == second; + const bool restored = prepared && ::chmod(path.toRawUTF8(), originalMode.st_mode & 07777) == 0; + const bool atomicFailure = rejected && retained && restored; + check("json_document_posix_rename_failure_preserves_both_files", atomicFailure); + posixPublicationFailureStatus = atomicFailure ? "pass" : "fail"; + } #endif } { @@ -1259,6 +1370,9 @@ int RuntimeSafetyRegression::run(const juce::File& directory) report->setProperty("checks", checks); report->setProperty("subjectiveAudio", "not_asserted"); report->setProperty("hardwareDrivers", "not_asserted"); +#if !JUCE_WINDOWS + report->setProperty("posixAtomicPublicationPermissionFailure", posixPublicationFailureStatus); +#endif #if defined(__SANITIZE_ADDRESS__) report->setProperty("addressSanitizer", true); #else diff --git a/Source/TrackProcessor.cpp b/Source/TrackProcessor.cpp index dcf98d1..928b2b1 100644 --- a/Source/TrackProcessor.cpp +++ b/Source/TrackProcessor.cpp @@ -15,6 +15,7 @@ #include #include #include +#include // Maximum channel count for the pre-allocated FX processing buffer. // Must be large enough for multi-output instruments (e.g. Komplete Kontrol = 32 out). @@ -1616,6 +1617,19 @@ void TrackProcessor::publishRealtimeStateSnapshots() next->inputFXPrecisionOverrides = inputFXForceFloatOverrides; next->trackFXPrecisionOverrides = trackFXForceFloatOverrides; next->instrument = instrumentPlugin; + const auto allNativeNAM = [] (const ProcessorSnapshot& processors) + { + return std::all_of(processors.begin(), processors.end(), [] (const ProcessorPtr& processor) + { + return dynamic_cast(processor.get()) != nullptr; + }); + }; + // Classify on publication, never by names or a callback-time plugin scan. + // Bypassed hosted slots also disqualify the graph: a bypass edit must not + // introduce an unqualified processor into a worker's processing path. + next->containsOnlyNativeNAM = next->instrument == nullptr + && (!next->inputFX.empty() || !next->trackFX.empty()) + && allNativeNAM(next->inputFX) && allNativeNAM(next->trackFX); int outputChannels = 2; const auto includeOutputs = [&] (const auto& processors) { @@ -2061,8 +2075,9 @@ double TrackProcessor::getOfflineRenderTailLengthSeconds() const const auto* synth = dynamic_cast(processor); const auto* piano = dynamic_cast(processor); const auto* guitar = dynamic_cast(processor); + const auto* drums = dynamic_cast(processor); double tail = processor->getTailLengthSeconds(); - if ((!synth && !piano && !guitar) || !automationSnapshot) return tail; + if ((!synth && !piano && !guitar && !drums) || !automationSnapshot) return tail; for (const auto& route : *automationSnapshot) { if (!route || route->fxIndex != fxIndex || route->isInputFX != isInputFX @@ -2073,9 +2088,10 @@ double TrackProcessor::getOfflineRenderTailLengthSeconds() const // conservatively reserve the maximum for those instrument routes. const bool relevant = id.isEmpty() || id == "releaseMs" || (synth && id.startsWith("matrix")) + || (drums && (id.startsWith("pieceDecay") || id == "articulationEngine")) || ((piano || guitar) && (id == "coupledBody" || id == "bodyDecay")); if (relevant) return synth ? synth->getMaximumTailLengthSeconds() - : piano ? piano->getMaximumTailLengthSeconds() : guitar->getMaximumTailLengthSeconds(); + : piano ? piano->getMaximumTailLengthSeconds() : guitar ? guitar->getMaximumTailLengthSeconds() : drums->getMaximumTailLengthSeconds(); } return tail; }; @@ -2566,11 +2582,182 @@ bool TrackProcessor::tryProcessBlock(juce::AudioBuffer& buffer, juce::Mid return true; } -void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +bool TrackProcessor::isParallelNAMGraphEligible(const RealtimeGraphSnapshot* graph) const noexcept +{ + return graph != nullptr && graph->generation != 0 && graph->containsOnlyNativeNAM + && trackType.load(std::memory_order_acquire) == TrackType::Audio + && araFXIndexForRealtime.load(std::memory_order_acquire) < 0; +} + +uint64 TrackProcessor::getParallelNAMGraphGeneration() const noexcept +{ + const ScopedTrackRealtimeReader graphReadGuard(realtimeGraphAudioReaders); + const auto* const graph = realtimeGraphSnapshotForAudio.load(std::memory_order_seq_cst); + return isParallelNAMGraphEligible(graph) ? graph->generation : 0; +} + +bool TrackProcessor::tryProcessBlockParallel(juce::AudioBuffer& buffer, + juce::MidiBuffer& midiMessages, + uint64 expectedGeneration) +{ + if (expectedGeneration == 0) + return false; + + // Control-side chain and precision edits already use this lock. A worker + // may reject the block but must never wait for them. Device/offline + // preparation and destruction additionally require the host's worker drain. + const juce::ScopedTryLock processingGuard(getCallbackLock()); + if (!processingGuard.isLocked()) + return false; + const ScopedTrackRealtimeReader graphReadGuard(realtimeGraphAudioReaders); + const auto* const graph = realtimeGraphSnapshotForAudio.load(std::memory_order_seq_cst); + if (!isParallelNAMGraphEligible(graph) || graph->generation != expectedGeneration) + return false; + if (preparedRealtimeBlockCapacity <= 0 + || buffer.getNumSamples() > preparedRealtimeBlockCapacity + || buffer.getNumChannels() < 1 || buffer.getNumChannels() > getProcessingChannelCount() + || !ProcessorSafety::isFinite(buffer)) + return false; + if (buffer.getNumSamples() == 0) + return true; + + processBlockInternal(buffer, midiMessages, graph); + return true; +} + +juce::var TrackProcessor::runParallelNAMEligibilityRegression() +{ + constexpr double sampleRate = 48000.0; + constexpr int capacity = 16; + auto serial = std::make_unique(); + auto parallel = std::make_unique(); + const bool emptyRejected = parallel->getParallelNAMGraphGeneration() == 0; + bool configured = serial->addTrackFX(std::make_unique(), sampleRate, capacity) + && parallel->addTrackFX(std::make_unique(), sampleRate, capacity); + serial->prepareToPlay(sampleRate, capacity); + parallel->prepareToPlay(sampleRate, capacity); + auto* serialRack = dynamic_cast(serial->getTrackFXProcessor(0)); + auto* parallelRack = dynamic_cast(parallel->getTrackFXProcessor(0)); + configured = configured && serialRack != nullptr && parallelRack != nullptr; + const auto processedBlocks = [] (const OpenStudioNAMRack* rack) + { + return rack != nullptr ? static_cast( + rack->getDiagnosticState().getProperty("processedBlockCount", -1)) : -1; + }; + const auto sameAudio = [] (const juce::AudioBuffer& left, const juce::AudioBuffer& right) + { + if (left.getNumChannels() != right.getNumChannels() || left.getNumSamples() != right.getNumSamples()) + return false; + for (int channel = 0; channel < left.getNumChannels(); ++channel) + for (int sample = 0; sample < left.getNumSamples(); ++sample) + if (left.getSample(channel, sample) != right.getSample(channel, sample)) + return false; + return true; + }; + bool parity = configured; + juce::int64 blockCount = 0; + for (int iteration = 0; iteration < 32 && configured; ++iteration) + { + for (int length : {1, 8, 16}) + { + juce::AudioBuffer reference(2, length), candidate(2, length); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < length; ++sample) + reference.setSample(channel, sample, 0.025f * static_cast((sample + iteration + channel) % 7 - 3)); + candidate.makeCopyOf(reference); + juce::MidiBuffer referenceMidi, candidateMidi; + serial->setCurrentBlockPosition(static_cast(iteration) * capacity / sampleRate); + parallel->setCurrentBlockPosition(static_cast(iteration) * capacity / sampleRate); + const auto generation = parallel->getParallelNAMGraphGeneration(); + parity = serial->tryProcessBlock(reference, referenceMidi) + && parallel->tryProcessBlockParallel(candidate, candidateMidi, generation) + && sameAudio(reference, candidate) && parity; + ++blockCount; + } + } + parity = parity && processedBlocks(serialRack) == blockCount && processedBlocks(parallelRack) == blockCount; + + juce::AudioBuffer input(2, capacity); + for (int channel = 0; channel < 2; ++channel) + juce::FloatVectorOperations::fill(input.getWritePointer(channel), 0.125f, capacity); + const auto rejectsUnchanged = [&] (uint64 generation) + { + juce::AudioBuffer candidate; + candidate.makeCopyOf(input); + juce::MidiBuffer midi; + midi.addEvent(juce::MidiMessage::noteOn(1, 60, juce::uint8{100}), 0); + const auto before = processedBlocks(parallelRack); + return !parallel->tryProcessBlockParallel(candidate, midi, generation) + && sameAudio(input, candidate) && midi.getNumEvents() == 1 + && processedBlocks(parallelRack) == before; + }; + const auto originalGeneration = parallel->getParallelNAMGraphGeneration(); + const bool zeroRejected = rejectsUnchanged(0); + parallel->bypassTrackFX(0, true); + const auto bypassGeneration = parallel->getParallelNAMGraphGeneration(); + const bool staleRejected = originalGeneration != 0 && bypassGeneration != originalGeneration + && rejectsUnchanged(originalGeneration); + parallel->bypassTrackFX(0, false); + const auto currentGeneration = parallel->getParallelNAMGraphGeneration(); + parallel->setTrackType(TrackType::MIDI); + const bool midiRejected = parallel->getParallelNAMGraphGeneration() == 0 && rejectsUnchanged(currentGeneration); + parallel->setTrackType(TrackType::Instrument); + const bool instrumentRejected = parallel->getParallelNAMGraphGeneration() == 0 && rejectsUnchanged(currentGeneration); + parallel->setTrackType(TrackType::Audio); + parallel->araFXIndexForRealtime.store(0, std::memory_order_release); + const bool araRejected = parallel->getParallelNAMGraphGeneration() == 0 && rejectsUnchanged(currentGeneration); + parallel->araFXIndexForRealtime.store(-1, std::memory_order_release); + + bool busyRejected = false; + { + juce::WaitableEvent lockHeld, releaseLock; + std::thread control([&] + { + const juce::ScopedLock lock(parallel->getCallbackLock()); + lockHeld.signal(); + releaseLock.wait(2000); + }); + if (lockHeld.wait(2000)) + busyRejected = rejectsUnchanged(currentGeneration); + releaseLock.signal(); + control.join(); + } + struct NameOnlyNAMPretender final : OpenStudioEQ + { + const juce::String getName() const override { return "OpenStudio NAM Rack"; } + }; + const bool nonNAMAdded = parallel->addInputFX(std::make_unique(), sampleRate, capacity); + const bool nonNAMRejected = nonNAMAdded && parallel->getParallelNAMGraphGeneration() == 0 + && rejectsUnchanged(currentGeneration); + parallel->bypassInputFX(0, true); + const bool bypassedNonNAMRejected = parallel->getParallelNAMGraphGeneration() == 0; + parallel->removeInputFX(0); + const bool eligibilityRecovered = parallel->getParallelNAMGraphGeneration() != 0; + parallel->releaseResources(); + const bool releasedRejected = rejectsUnchanged(parallel->getParallelNAMGraphGeneration()); + auto* result = new juce::DynamicObject(); + result->setProperty("pass", emptyRejected && zeroRejected && parity && staleRejected && midiRejected + && instrumentRejected && araRejected && busyRejected && nonNAMRejected && bypassedNonNAMRejected + && eligibilityRecovered && releasedRejected); + result->setProperty("emptyAndZeroRejected", emptyRejected && zeroRejected); + result->setProperty("serialWorkerExactParity", parity); + result->setProperty("parityBlocks", blockCount); + result->setProperty("staleGenerationRejectedWithoutProcessing", staleRejected); + result->setProperty("midiInstrumentAndARARejected", midiRejected && instrumentRejected && araRejected); + result->setProperty("busyControlLockRejectedWithoutProcessing", busyRejected); + result->setProperty("nonNAMIdentityAndBypassRejected", nonNAMRejected && bypassedNonNAMRejected); + result->setProperty("eligibilityRecovered", eligibilityRecovered); + result->setProperty("releasedCapacityRejected", releasedRejected); + result->setProperty("scope", "Device-free graph eligibility and single-owner entrypoint contracts; scheduler timing and concurrent lifecycle qualification are separate."); + return juce::var(result); +} + +void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages, + const RealtimeGraphSnapshot* workerGraph) { const int activeARAFXIndexForBlock = - araFXIndexForRealtime.load( - std::memory_order_acquire); + workerGraph != nullptr ? -1 + : araFXIndexForRealtime.load(std::memory_order_acquire); auto* const activeARAControllerForBlock = activeARAFXIndexForBlock >= 0 ? araController.get() @@ -2584,15 +2771,16 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc juce::ScopedNoDenormals noDenormals; auto totalNumInputChannels = getTotalNumInputChannels(); auto totalNumOutputChannels = getTotalNumOutputChannels(); - const auto currentTrackType = trackType.load(std::memory_order_acquire); + const auto currentTrackType = workerGraph != nullptr + ? TrackType::Audio : trackType.load(std::memory_order_acquire); // Avoid MSVC's process-wide atomic spin lock in the audio // callback. Control-side publication retains replaced immutable graphs // until this reader epoch has drained. const ScopedTrackRealtimeReader graphReadGuard( - realtimeGraphAudioReaders); + realtimeGraphAudioReaders, workerGraph == nullptr); const auto* const graphSnapshot = - realtimeGraphSnapshotForAudio.load( - std::memory_order_seq_cst); + workerGraph != nullptr ? workerGraph + : realtimeGraphSnapshotForAudio.load(std::memory_order_seq_cst); const uint64 graphGeneration = graphSnapshot != nullptr ? graphSnapshot->generation : 0; const auto* const inputFXSnapshot = diff --git a/Source/TrackProcessor.h b/Source/TrackProcessor.h index 1a58d8d..b75e17d 100644 --- a/Source/TrackProcessor.h +++ b/Source/TrackProcessor.h @@ -530,6 +530,16 @@ class TrackProcessor : public juce::AudioProcessor, void setForceAutomationReadForProcessing(bool forceRead) { forceAutomationReadDuringProcessing.store(forceRead, std::memory_order_relaxed); } bool tryProcessBlock(juce::AudioBuffer&, juce::MidiBuffer&); + // A zero generation is ineligible. This is a hint until the worker checks + // the same generation under its graph-reader lease. Dependencies, track + // lifetime, block context and exclusive processing remain the host's job. + uint64 getParallelNAMGraphGeneration() const noexcept; + // Rejection leaves audio/MIDI and DSP state untouched. A successful call + // processes only the validated native-NAM graph, even if a new graph is + // published during the block. Never retry a still-running job elsewhere. + bool tryProcessBlockParallel(juce::AudioBuffer&, juce::MidiBuffer&, + uint64 expectedGeneration); + static juce::var runParallelNAMEligibilityRegression(); void setDiagnosticsTrackId(const juce::String& id) { diagnosticsTrackId = id; } int getProcessorFault(bool input, int index) const; @@ -614,7 +624,10 @@ class TrackProcessor : public juce::AudioProcessor, juce::String builtInParamId; }; - void processBlockInternal(juce::AudioBuffer&, juce::MidiBuffer&); + struct RealtimeGraphSnapshot; + bool isParallelNAMGraphEligible(const RealtimeGraphSnapshot*) const noexcept; + void processBlockInternal(juce::AudioBuffer&, juce::MidiBuffer&, + const RealtimeGraphSnapshot* workerGraph = nullptr); void timerCallback() override; void refreshRealtimeFXTailBudgetOnControlThread(); void resetExpiredRealtimeFXTailOnControlThread(); @@ -721,6 +734,7 @@ class TrackProcessor : public juce::AudioProcessor, struct RealtimeGraphSnapshot { uint64 generation = 0; + bool containsOnlyNativeNAM = false; ProcessorSnapshot inputFX; ProcessorSnapshot trackFX; BypassSnapshot inputFXBypass; diff --git a/Source/UpdateInstaller.cpp b/Source/UpdateInstaller.cpp index deab558..f712f94 100644 --- a/Source/UpdateInstaller.cpp +++ b/Source/UpdateInstaller.cpp @@ -3,6 +3,7 @@ #include "UpdateManifest.h" #include #include +#include #include #include #if defined(OPENSTUDIO_INSTALLER_TEST_FIXTURES) @@ -244,6 +245,23 @@ bool validTransaction(const juce::File& transaction) } } +bool ensurePrivateDirectory(const juce::File& directory) +{ + if (directory == juce::File()) return false; + struct stat info {}; + if (::lstat(directory.getFullPathName().toRawUTF8(), &info) == 0) + return safePath(directory, true); + if (errno != ENOENT) return false; + + const auto parent = directory.getParentDirectory(); + if (parent == directory || !ensurePrivateDirectory(parent)) return false; + // A private mode does not acquire group/other permissions under a permissive + // umask. Do not chmod an existing path, including an EEXIST race winner. + if (::mkdir(directory.getFullPathName().toRawUTF8(), 0700) != 0 && errno != EEXIST) + return false; + return safePath(directory, true); +} + juce::File installedApplication() { #if JUCE_MAC @@ -286,7 +304,7 @@ bool prepare(const juce::File& package, const juce::var& envelope, return false; } const auto root = stateRoot.getChildFile("install-transactions"); - if (!root.createDirectory() || !safePath(root, true)) { error = "The updater state folder is not secure or writable."; return false; } + if (!ensurePrivateDirectory(root)) { error = "The updater state folder is not secure or writable."; return false; } transaction = root.getChildFile(juce::Uuid().toString()); if (::mkdir(transaction.getFullPathName().toRawUTF8(), 0700) != 0) { error = "Cannot create an update transaction."; return false; } auto request = juce::var(new juce::DynamicObject()); @@ -398,7 +416,7 @@ static int runTransaction(const juce::File& transaction, const juce::String& tru const auto image = workspace.getChildFile("update.dmg"); if (!package.copyFileTo(image) || juce::SHA256(image).toHexString() != platform["sha256"].toString()) return fail("Could not securely copy the update disk image."); const auto mount = workspace.getChildFile("mount"); - if (!mount.createDirectory()) return fail("Could not create the disk image mount point."); + if (!ensurePrivateDirectory(mount)) return fail("Could not create the disk image mount point."); juce::String output; if (!command({ "/usr/bin/hdiutil", "attach", "-readonly", "-nobrowse", "-noautoopen", "-mountpoint", mount.getFullPathName(), image.getFullPathName() }, output)) return fail("Could not mount the verified update disk image: " + output); @@ -506,7 +524,7 @@ int runHelper(const juce::File& transaction) int selfTest(const juce::File& directory) { const auto root = directory.getChildFile(juce::Uuid().toString()); - if (!root.createDirectory()) return 2; + if (!ensurePrivateDirectory(root)) return 2; const auto a = root.getChildFile("old app"), b = root.getChildFile("new app"); if (!a.replaceWithText("old") || !b.replaceWithText("new")) return 2; const auto oldHash = fingerprint(a), newHash = fingerprint(b); @@ -518,6 +536,49 @@ int selfTest(const juce::File& directory) value.getDynamicObject()->setProperty("pass", result); checks.add(value); pass = pass && result; }; + auto modeOf = [](const juce::File& path) -> mode_t { + struct stat info {}; + return ::lstat(path.getFullPathName().toRawUTF8(), &info) == 0 + ? static_cast(info.st_mode & 0777) : static_cast(0); + }; + struct ScopedTestUmask + { + explicit ScopedTestUmask(mode_t value) : previous(::umask(value)) {} + ~ScopedTestUmask() { ::umask(previous); } + mode_t previous; + }; + for (const auto mask : { static_cast(0000), static_cast(0002), + static_cast(0022), static_cast(0077) }) + { + const ScopedTestUmask scopedMask(mask); + const auto parent = root.getChildFile("private-" + juce::String(static_cast(mask))); + const auto child = parent.getChildFile("nested"); + check(("private_directories_ignore_umask_" + juce::String(static_cast(mask))).toRawUTF8(), + ensurePrivateDirectory(child) && modeOf(parent) == 0700 && modeOf(child) == 0700); + } + const auto existing = root.getChildFile("existing-secure"); + const bool existingCreated = ensurePrivateDirectory(existing) + && ::chmod(existing.getFullPathName().toRawUTF8(), 0750) == 0; + check("existing_secure_directory_mode_preserved", existingCreated + && ensurePrivateDirectory(existing) && modeOf(existing) == 0750); + const auto insecure = root.getChildFile("existing-insecure"); + const bool insecureCreated = ensurePrivateDirectory(insecure) + && ::chmod(insecure.getFullPathName().toRawUTF8(), 0775) == 0; + check("existing_insecure_directory_rejected_without_chmod", insecureCreated + && !ensurePrivateDirectory(insecure) && modeOf(insecure) == 0775); + check("insecure_ancestor_rejected_before_child_creation", insecureCreated + && !ensurePrivateDirectory(insecure.getChildFile("child")) + && !insecure.getChildFile("child").exists() && modeOf(insecure) == 0775); + const auto directoryLink = root.getChildFile("directory-link"); + const bool linkCreated = ::symlink(existing.getFullPathName().toRawUTF8(), + directoryLink.getFullPathName().toRawUTF8()) == 0; + check("existing_directory_symlink_rejected", linkCreated + && !ensurePrivateDirectory(directoryLink) && modeOf(existing) == 0750); + check("symlink_ancestor_rejected_before_child_creation", linkCreated + && !ensurePrivateDirectory(directoryLink.getChildFile("child")) + && !existing.getChildFile("child").exists() && modeOf(existing) == 0750); + check("regular_file_cannot_be_private_directory", !ensurePrivateDirectory(a) + && a.loadFileAsString() == "old"); check("atomic_file_replacement_retains_old_bytes", oldHash.isNotEmpty() && oldHash != newHash && swapVerified(a, b, oldHash, newHash) && fingerprint(a) == newHash && fingerprint(b) == oldHash); check("rollback_restores_exact_original", swapVerified(a, b, newHash, oldHash) && fingerprint(a) == oldHash); @@ -546,25 +607,31 @@ int selfTest(const juce::File& directory) check("journal_markers_cannot_be_overwritten", writeMarker(root, "once", "one") && !writeMarker(root, "once", "two")); check("failed_exchange_preserves_target", !exchange(a, root.getChildFile("missing")) && a.loadFileAsString() == "old"); const auto oldBundle = root.getChildFile("Old.app"), newBundle = root.getChildFile("New.app"); - oldBundle.getChildFile("Contents").createDirectory(); newBundle.getChildFile("Contents").createDirectory(); + const bool bundlesCreated = ensurePrivateDirectory(oldBundle.getChildFile("Contents")) + && ensurePrivateDirectory(newBundle.getChildFile("Contents")); oldBundle.getChildFile("Contents/file").replaceWithText("old bundle"); newBundle.getChildFile("Contents/file").replaceWithText("new bundle"); const auto oldBundleHash = fingerprint(oldBundle, true), newBundleHash = fingerprint(newBundle, true); - check("atomic_bundle_swap_and_rollback", swapVerified(oldBundle, newBundle, oldBundleHash, newBundleHash) + check("atomic_bundle_swap_and_rollback", bundlesCreated && swapVerified(oldBundle, newBundle, oldBundleHash, newBundleHash) && oldBundle.getChildFile("Contents/file").loadFileAsString() == "new bundle" && swapVerified(oldBundle, newBundle, newBundleHash, oldBundleHash) && oldBundle.getChildFile("Contents/file").loadFileAsString() == "old bundle"); const auto escapingLink = oldBundle.getChildFile("escape"); check("bundle_link_escape_rejected", ::symlink(a.getFullPathName().toRawUTF8(), escapingLink.getFullPathName().toRawUTF8()) == 0 && fingerprint(oldBundle).isEmpty()); - const auto tx = root.getChildFile(juce::Uuid().toString()); tx.createDirectory(); + const auto tx = root.getChildFile(juce::Uuid().toString()); + const bool txCreated = ensurePrivateDirectory(tx); cancel(tx); commit(tx); - check("cancelled_transaction_cannot_commit", exists(tx, "cancel") && !exists(tx, "commit")); - const auto unprepared = root.getChildFile(juce::Uuid().toString()); unprepared.createDirectory(); commit(unprepared); - check("unprepared_transaction_cannot_commit", !exists(unprepared, "commit")); - const auto prepared = root.getChildFile(juce::Uuid().toString()); prepared.createDirectory(); + check("cancelled_transaction_cannot_commit", txCreated && exists(tx, "cancel") && !exists(tx, "commit")); + const auto unprepared = root.getChildFile(juce::Uuid().toString()); + const bool unpreparedCreated = ensurePrivateDirectory(unprepared); + commit(unprepared); + check("unprepared_transaction_cannot_commit", unpreparedCreated && !exists(unprepared, "commit")); + const auto prepared = root.getChildFile(juce::Uuid().toString()); + const bool preparedCreated = ensurePrivateDirectory(prepared); writeMarker(prepared, "ready", "ready"); commit(prepared); - check("prepared_transaction_requires_explicit_commit", prepared.getChildFile("commit").loadFileAsString() == "normal shutdown"); + check("prepared_transaction_requires_explicit_commit", preparedCreated + && prepared.getChildFile("commit").loadFileAsString() == "normal shutdown"); #if defined(OPENSTUDIO_INSTALLER_TEST_FIXTURES) runIntegrationFixtures(root, check); #endif @@ -574,6 +641,7 @@ int selfTest(const juce::File& directory) return pass ? 0 : 2; } #else +bool ensurePrivateDirectory(const juce::File& directory) { return directory.createDirectory().wasOk(); } juce::File installedApplication() { return {}; } bool prepare(const juce::File&, const juce::var&, const juce::File&, juce::File&, juce::String&, std::function) { return false; } void cancel(const juce::File&) {} diff --git a/Source/UpdateInstaller.h b/Source/UpdateInstaller.h index 0b0d037..a1e1fa7 100644 --- a/Source/UpdateInstaller.h +++ b/Source/UpdateInstaller.h @@ -5,6 +5,10 @@ // shutdown. The independent helper never terminates an application process. namespace UpdateInstaller { +// POSIX staging directories are created with mode 0700. Existing directories +// and ancestors must already satisfy the updater's ownership/trust policy; +// their permissions are never changed. Windows retains normal inherited ACLs. +bool ensurePrivateDirectory(const juce::File& directory); juce::File installedApplication(); bool prepare(const juce::File& package, const juce::var& envelope, const juce::File& stateRoot, juce::File& transaction, juce::String& error, diff --git a/Source/UpdateInstallerIntegration.h b/Source/UpdateInstallerIntegration.h index b57d86d..02fa324 100644 --- a/Source/UpdateInstallerIntegration.h +++ b/Source/UpdateInstallerIntegration.h @@ -11,17 +11,17 @@ static void runIntegrationFixtures(const juce::File& root, Check check) for (const auto* scenario : { "healthy", "exit", "cancel", "no-commit", "late", "tamper", "another-instance" }) { const auto scenarioRoot = root.getChildFile(scenario); - scenarioRoot.createDirectory(); + const bool scenarioReady = ensurePrivateDirectory(scenarioRoot); const auto target = scenarioRoot.getChildFile(macOS ? "OpenStudio.app" : "OpenStudio.AppImage"); const auto transaction = scenarioRoot.getChildFile(juce::Uuid().toString()); - transaction.createDirectory(); - writeMarker(transaction, "fixture-behaviour", scenario); + const bool transactionReady = scenarioReady && ensurePrivateDirectory(transaction); + if (transactionReady) writeMarker(transaction, "fixture-behaviour", scenario); const auto package = scenarioRoot.getChildFile(macOS ? "fixture.dmg" : "fixture.AppImage"); bool setup = false; #if JUCE_MAC auto makeApp = [&](const juce::File& app, const char* version) { const auto exe = app.getChildFile("Contents/MacOS/OpenStudio"); - exe.getParentDirectory().createDirectory(); + if (!ensurePrivateDirectory(exe.getParentDirectory())) return false; const auto plist = "" "CFBundleIdentifierorg.openstudio.updaterfixture" "CFBundleExecutableOpenStudio" @@ -31,11 +31,11 @@ static void runIntegrationFixtures(const juce::File& root, Check check) && app.getChildFile("Contents/Info.plist").replaceWithText(plist); }; const auto contents = scenarioRoot.getChildFile("image-content"); - setup = makeApp(target, "0.0.1") && makeApp(contents.getChildFile("OpenStudio.app"), "999.1.1"); + setup = transactionReady && makeApp(target, "0.0.1") && makeApp(contents.getChildFile("OpenStudio.app"), "999.1.1"); juce::String output; setup = setup && command({ "/usr/bin/hdiutil", "create", "-srcfolder", contents.getFullPathName(), "-format", "UDZO", package.getFullPathName() }, output); #else - setup = fixture.copyFileTo(package) && fixture.copyFileTo(target) && target.setExecutePermission(true); + setup = transactionReady && fixture.copyFileTo(package) && fixture.copyFileTo(target) && target.setExecutePermission(true); // A native ELF with the AppImage type-2 marker exercises the real helper // without requiring a FUSE mount. Desktop/FUSE qualification is separate. Descriptor fd { ::open(package.getFullPathName().toRawUTF8(), O_WRONLY | O_CLOEXEC) }; diff --git a/Source/UpdaterRegression.h b/Source/UpdaterRegression.h index 0283313..e380e07 100644 --- a/Source/UpdaterRegression.h +++ b/Source/UpdaterRegression.h @@ -1,7 +1,12 @@ #pragma once #include "AppUpdater.h" #include "UpdateManifest.h" +#include "UpdateInstaller.h" +#include "RuntimeLocation.h" #include +#if JUCE_MAC || JUCE_LINUX + #include +#endif // Headless fixtures use an ephemeral test key. No network or installer launch. class UpdaterRegression @@ -20,6 +25,63 @@ class UpdaterRegression auto* item = new juce::DynamicObject(); item->setProperty("name", name); item->setProperty("pass", pass); checks.add(juce::var(item)); }; + #if JUCE_MAC || JUCE_LINUX + // The report may be under /tmp, which is deliberately not trusted for + // executable staging. Exercise fresh profile creation under the user's + // trusted home instead, without touching the actual application profile. + const auto privateRoot = juce::File::getSpecialLocation(juce::File::userHomeDirectory) + .getChildFile(".openstudio-updater-regression-" + juce::Uuid().toString()); + const bool rootCreated = !privateRoot.exists() + && UpdateInstaller::ensurePrivateDirectory(privateRoot); + check("private_profile_fixture_created", rootCreated); + if (rootCreated) + { + struct ScopedTestUmask + { + explicit ScopedTestUmask(mode_t value) : previous(::umask(value)) {} + ~ScopedTestUmask() { ::umask(previous); } + mode_t previous; + }; + auto modeOf = [](const juce::File& path) -> mode_t { + struct stat info {}; + return ::lstat(path.getFullPathName().toRawUTF8(), &info) == 0 + ? static_cast(info.st_mode & 0777) : static_cast(0); + }; + for (const auto mask : { static_cast(0002), static_cast(0077) }) + { + const ScopedTestUmask scopedMask(mask); + const auto profile = privateRoot.getChildFile("profile-" + juce::String(static_cast(mask))); + AppUpdater fresh(profile); + const bool packageManaged = OpenStudioRuntime::nativePackageFormat().isNotEmpty(); + check(((packageManaged ? "native_package_constructor_skips_profile_" : "constructor_creates_private_profile_") + + juce::String(static_cast(mask))).toRawUTF8(), + packageManaged ? !profile.exists() : modeOf(profile) == 0700); + fresh.persistedState = juce::JSON::parse(R"({"lastSeenVersion":"999.1.1"})"); + const auto downloads = profile.getChildFile("updates"); + const auto stagedFolder = downloads.getChildFile(juce::Uuid().toString()); + check(("persist_then_stage_private_profile_" + juce::String(static_cast(mask))).toRawUTF8(), + fresh.savePersistedState() + && juce::JSON::parse(profile.getChildFile("updater-state.json"))["lastSeenVersion"] == "999.1.1" + && UpdateInstaller::ensurePrivateDirectory(stagedFolder) + && modeOf(profile) == 0700 && modeOf(downloads) == 0700 && modeOf(stagedFolder) == 0700); + // The profile may be removed between construction and a later + // check; persistence must not recreate it with ambient 0775. + const bool removed = profile.deleteRecursively(); + check(("persist_recreates_private_profile_" + juce::String(static_cast(mask))).toRawUTF8(), + removed && fresh.savePersistedState() && modeOf(profile) == 0700); + } + const auto existingProfile = privateRoot.getChildFile("existing-insecure-profile"); + const bool existingCreated = UpdateInstaller::ensurePrivateDirectory(existingProfile) + && ::chmod(existingProfile.getFullPathName().toRawUTF8(), 0775) == 0; + AppUpdater existing(existingProfile); + existing.persistedState = juce::JSON::parse(R"({"lastSeenVersion":"999.1.1"})"); + check("existing_profile_metadata_preserved_but_staging_rejected", existingCreated + && existing.savePersistedState() && modeOf(existingProfile) == 0775 + && !UpdateInstaller::ensurePrivateDirectory(existingProfile.getChildFile("updates")) + && !existingProfile.getChildFile("updates").exists()); + check("private_profile_fixture_removed", privateRoot.deleteRecursively()); + } + #endif uint8_t seed[32] {}, secret[64] {}, key[32] {}; for (int i = 0; i < 32; ++i) seed[i] = static_cast(i + 1); crypto_ed25519_key_pair(secret, key, seed); diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 85220db..9d8dce7 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -66,7 +66,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, macOS package, or Linux AppImage from the official download page. +1. Download the Windows installer, macOS package, or Linux package qualified for your distribution 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. @@ -84,20 +84,25 @@ If startup reports that the embedded browser is unavailable, **Repair Dependenci **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. -Launch the AppImage file itself; copying only its internal `OpenStudio` executable -leaves out the `webui`, effects, and script assets it needs to start. - -**Linux native installers (development candidate, not yet publicly released):** -Ubuntu, Debian and Mint use `.deb`; Fedora uses `.rpm`. Open the matching package +**Linux native packages:** Ubuntu, Debian and Mint use `.deb`; Fedora uses +`.rpm` when a qualified package is supplied. The public v0.1.05 release includes +an Ubuntu 22.04 amd64 DEB and an AppImage, with no RPM asset. Use the package +qualified for your distribution/version; DEB is not an Arch-native installer. +Open the matching package with the desktop software installer, approve normal administrator authentication, then launch **OpenStudio** from the application menu. The package manager installs runtime dependencies, including FFmpeg and credential-storage tools. Development headers are not required. Native-package updates use the matching package again; **Help > Check for Updates...** links to the download page. Do not run the app as root. +**Linux AppImage:** when using the portable download, mark it executable and +launch the AppImage file itself. Copying only its internal `OpenStudio` +executable leaves out the `webui`, effects and script assets it needs to start. +FFmpeg-backed operations use an optional system `ffmpeg` on `PATH`; the AppImage +does not bundle an arbitrary host FFmpeg binary. AppImage availability does not +establish compatibility with every distribution. See the +[package qualification evidence](linux-installation-and-qualification-plan.md#published-package-recheck-2026-10-22-issue-23). + If double-clicking a `.deb` opens **Archive Manager**, close that window; do not extract the package. In Files, right-click the downloaded `.deb`, choose **Open With Other Application**, select **Software Install**, then click **Select**. @@ -145,7 +150,8 @@ When you first open OpenStudio, you should configure your audio settings: 5. Choose a **Sample Rate** (44100 Hz or 48000 Hz are standard). 6. Set a **Buffer Size** (lower values reduce latency but increase CPU load; 256 or 512 samples is a good starting point). 7. Click **Apply** to activate the settings and inspect the accepted values without - closing the dialog, or **OK** to apply and close it. + closing the dialog, or **OK** to apply and close it when the requested values + are accepted. In the development checkout, changing the audio system, WASAPI **Mode**, or input/output device refreshes the reported choices and selects the new device's @@ -154,6 +160,38 @@ or buffer also revalidates the pending selection. **Cancel**, Escape, and the close button discard unapplied changes; selecting an audio system no longer activates it immediately. +The October 23 development checkout labels the dialog's **Active audio** rate +and buffer separately from **Pending** edits. For example, selecting 64 samples +can show **Active audio: 44,100 Hz · 8 samples** and **Pending: 44,100 Hz · +64 samples** until Apply or OK. A supported rate or buffer edit on the same +device stays pending during discovery; it does not reopen the running stream. +After applying, check the active values before testing the complete project. +If no device is open, the dialog reports that instead of showing zero-valued +active settings. + +If discovery fails, the requested rate and buffer remain visible as pending; +use **Retry** or correct the selection before applying. Apply verifies an explicit +rate and buffer against the opened device. If a driver silently substitutes a +different value, the dialog reports both values and attempts to restore the +previous setup; the applied snapshot shows the actual recovery state. + +The October 22 development checkout also keeps **OK** open if an apply result +reports success with a different buffer or sample rate. It shows the requested +and accepted values, such as “Requested 16 samples; the driver accepted +8 samples,” and the controls show the active settings for review. This feedback +does not make an unsupported setting available or establish that a particular +ASIO driver issue is fixed. Check the running rate/buffer display and test the +actual interface before recording. + +Changing only the sample rate or buffer on the same audio system preserves an +explicitly disabled input or output, including input-only/output-only ASIO and +separate-device systems. The ASIO Driver label uses the active endpoint even +when only input is enabled. After the native driver control panel closes, the +dialog refreshes the actual configuration. If restarting the device failed, +it shows that error and the actual closed-device state; reselect the same driver +and Apply to attempt recovery. It does not keep displaying the old active +configuration as though the restart succeeded. + Inactive ASIO drivers are inspected for rates, buffer limits and preferred settings without opening an audio stream. Small buffers such as 8 and 16 samples appear when reported by that driver. An unavailable driver shows an error instead @@ -322,7 +360,7 @@ 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 with Primary+drag on the timeline background or Shift+drag on the ruler (`Primary` is Ctrl on Windows/Linux and Command on macOS). +- **Time selection**: A highlighted editing region created with Primary+drag on the timeline background or set/extended with Shift+drag on the ruler (`Primary` is Ctrl on Windows/Linux and Command on macOS). A plain horizontal ruler drag sets the separate project/loop range. A plain vertical ruler drag zooms time out upward and in downward, anchored to its starting time; direction locks after a small threshold. Click without dragging seeks. Explicit mouse-profile/custom ruler actions take priority; see [input profiles](input-profiles.md#mouse-and-wheel-safety). - **Razor edits**: Semi-transparent selection areas created with Alt/Option+drag on the timeline background for precise non-destructive editing. **Essential Navigation:** @@ -331,11 +369,11 @@ The Timeline is the central canvas-based workspace where you arrange audio and M - **Shift+Scroll**: horizontal timeline scroll. - **Alt+Scroll**: track height resize. - **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`. +- **First-session hotkeys**: `Space`, `R`, `1`, `+`/`=` and `-`, `Ctrl+T`, `Ctrl+M`, `S`, `B`, `Delete`, `Ctrl+S`, `F1`, `Ctrl+Shift+P`. These are OpenStudio-profile defaults; vendor profiles and custom overrides retain their effective bindings. - **Need a refresher?** Press `F1` for the searchable **Help Reference** and open **Help > Keyboard, Mouse & Trackpad** for the full shortcut list and custom scoped rebinding. **Zoom and Scroll:** -- **Horizontal zoom**: `Ctrl+Scroll wheel` (or `Ctrl++` / `Ctrl+-`). Zoom range: 1 to 1000 pixels per second. +- **Horizontal zoom**: `Ctrl+Scroll wheel`, `+`/`=` and `-` (also numpad `+`/`-`, with `Ctrl++` / `Ctrl+-` retained). Zoom range: 1 to 1000 pixels per second. Plain vertical ruler drag also zooms time. - **Playhead visibility**: the ruler marker and vertical line update together after zoom, scroll, or resize, including while stopped, whenever the playhead lies within the visible time range. - **Horizontal scroll**: Shift+scroll wheel, or use the horizontal scrollbar. - **Vertical scroll**: Scroll wheel when hovering over the track area. @@ -358,7 +396,7 @@ 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` +- **Record** (red circle): `R` starts recording on armed tracks; pressing it again finishes the recording. `Ctrl+R` and numpad `*` remain aliases. Audio/MIDI tracks and Normal/Overdub/Replace modes use the same command. - **Play / Pause** (green triangle): Starts or pauses playback. The default `Space` shortcut starts or stops transport, including an active recording. - **Stop** (square): Stops playback or recording. There is no separate default keyboard shortcut. - **Pause** (parallel bars): Pauses playback in place. @@ -642,7 +680,7 @@ When a track is armed, you can enable input monitoring to hear the live signal: 1. Arm the desired track(s). 2. Position the playhead where you want recording to begin. -3. Press the **Record** button in the Transport Bar or press `Ctrl+R`. +3. Press the **Record** button in the Transport Bar or press `R` (`Ctrl+R` and numpad `*` are aliases). Press `R` again to finish the recording. 4. The transport status changes to `[Recording]` and a red dot indicator appears. 5. Perform your recording. 6. Press `Space` or the **Stop** button to end recording. @@ -715,7 +753,7 @@ If the hardware MIDI queue loses messages, the sender discards the compromised b 3. Select your MIDI input device on the track. 4. Arm the track for recording. 5. Enable monitoring to hear the instrument while playing. -6. Press Record (`Ctrl+R`) and play your MIDI controller. +6. Press Record (`R`, also `Ctrl+R` or numpad `*`) and play your MIDI controller. Press `R` again to finish. 7. Press Stop when finished. 8. A MIDI clip appears on the timeline containing the recorded note and CC data. @@ -768,7 +806,7 @@ MIDI Learn allows you to map physical MIDI controller knobs, faders, and buttons - `Ctrl+A` to select all tracks. **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). +- Primary+drag on the timeline background creates a time selection; Shift+drag on the ruler sets or extends it (`Primary` is Ctrl on Windows/Linux and Command on macOS). Plain horizontal ruler drag changes the separate project/loop range and leaves the edit selection unchanged. These are OpenStudio-profile defaults; custom mappings take priority. - The time selection is highlighted as a shaded region. - Time selections are used for punch recording, rendering bounds, and editing operations. @@ -777,7 +815,7 @@ 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. - A click selects without moving or trimming the clip, even when it is off-grid. Small pointer movements below four screen pixels also count as a click. - When snap is enabled, the clip snaps to grid lines. -- Hold `Primary` while dragging to copy the clip instead of moving it. +- Hold `Primary` while dragging to copy the selected clip group with one Undo step. Half-transparent previews show its destination while originals stay in place. If compatible destination tracks must be appended, their span preserves source row order and gaps. - 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. @@ -854,6 +892,18 @@ Clipboard operations support: - Multi-clip copy/paste (preserves relative track positions) - Copy/Cut within time selection +`Ctrl+D` duplicates the selected clips by one shared time offset: the span from +the earliest selected start to the latest selected end. Relative gaps and track +positions stay intact instead of each clip moving by its own length. Hold Ctrl +(Command on macOS) while dragging a selected clip to copy the entire selected +group. The complete audio/MIDI preview is shown at 50% opacity; the originals +stay in place. Release to commit one Undo step, or press Escape to cancel. + +Holding Ctrl/Command while dragging a selected track header copies the entire +selected track group, including its clips, settings and FX. Drop on another row +to insert the copies before it. The track preview also shows 50% opacity, and +the whole operation is one Undo step. + ### 6.9 Nudging Clips Move selected clips by small increments: @@ -977,13 +1027,13 @@ The Piano Roll consists of several areas: 1. Select the **Select** tool. 2. Click a note to select it. -3. `Ctrl+Click` to add/remove notes from the selection. +3. `Shift+Click` to add/remove notes from the selection. 4. Click and drag on empty space to rubber-band select multiple notes. 5. Use **MIDI > Select All Notes** to select all notes in the clip. ### 7.5 Editing Notes -- **Move**: Drag selected notes to a new pitch/time position. +- **Move**: Drag selected notes to a new pitch/time position. Ctrl/Cmd+drag keeps each original pitch and moves time only, retaining the existing off-grid placement behavior. Shift+drag keeps each exact start time and moves pitch only. Constraints apply to the selected group; durations, velocities and relative spacing are preserved. Alt/Option+drag copies the notes; the complete copy-and-move is one Undo step. - **Resize**: Drag the right edge of a note to change its duration. - **Delete**: Select notes and press `Delete`, or use the **Erase** tool to click-delete individual notes. @@ -1129,6 +1179,13 @@ Sends route a copy of the track's signal to a bus track for effects processing ( - **Post-fader**: Send level follows the track fader. 5. Sends can be enabled/disabled individually. +**Development checkout:** a destination track's live hardware input now stays +mixed with its incoming sends while the transport is stopped, as well as +during playback. An armed destination's recording tap retains the incoming +send mixture with its live input, including during NAM worker-overload +recovery. An overloaded source can still interrupt its own contribution; +check the recorded file and monitoring separately. + ### 8.7 Bus and Group Tracks Bus tracks receive audio from sends and can apply their own FX chain: @@ -1195,28 +1252,34 @@ OpenStudio supports three FX chain positions per track: OpenStudio includes a set of built-in effects identified by the `OpenStudio` prefix in current releases. Legacy `OpenStudio` effect names are still accepted for compatibility in older projects and scripts. -**Approved editor redesign, 0.1.04 release candidate, October 8, 2026:** fifteen effect/instrument faces use the approved layouts. Pitch Correct opens the existing clip Edit Pitch workflow; it does not create a second graphical editor. These changes are included in the reviewed release candidate and are not yet published. NAM Rack retains its separate editor. Main controls remain visible in compact windows; detail panels scroll within the editor. +**Design 03 editors, development checkout, October 22, 2026:** the revised non-NAM interfaces are implemented in the working tree, with qualification in progress. They use the existing native settings and history. This does not describe a published installer or establish final listening, device or visual acceptance. NAM Rack retains its separate editor. Pitch Correct retains realtime FX settings and the existing recorded-clip Edit Pitch workflow. | Plugin | Editor | |---|---| | **OpenStudio EQ** | Full-width 24-band graph, Filter/Dynamics inspector, Minimum/Linear/Minimum FIR selector, output strip; advanced analyzer, matching and MIDI program tools remain available | -| **OpenStudio Graphic EQ** | Independent 10/31-band banks, all 31 faders in a wide window, frequency pages in compact windows, grouped edits and output trim | +| **OpenStudio Graphic EQ** | Independent 10/31-band banks, all 31 faders in a wide window, frequency pages in compact windows, grouped edits, Flat bank, Reset selected and output trim | | **OpenStudio Compressor** | Model-specific primary controls, native level/reduction history, detector, linking and character details | | **OpenStudio Gate / Limiter** | Primary dynamics controls, native reduction/status readouts and model-relevant advanced controls | | **OpenStudio Delay** | Independent stereo timing, free/sync mode, host-tempo status, feedback, balance and colour; Motion, Ducking, Routing and Dual Engine B details | -| **OpenStudio Reverb** | 41 types with contextual controls, wet/dry balance, native level readouts and the existing IR loading, preview, shape and output workflows | +| **OpenStudio Reverb** | Search/category browser for 41 types, contextual machine pages, shared Setup policies, wet/dry/output strip, native readings, Response analysis and the existing IR workflows | | **OpenStudio Preamp / Saturator** | Drive, tone and output hierarchy with native signal-level feedback; retained engines and detailed colour controls | | **OpenStudio Chorus** | Chorus/Flanger/Phaser modes, contextual voices/stages, independent modulation divisions and tone controls | | **OpenStudio Gain Phase** | Manual gain, left/right timing and polarity, existing group alignment, all-pass phase shaping and spectral phase settings | | **OpenStudio Pitch Correct** | Opens the existing Edit Pitch session for an explicitly chosen eligible audio clip; realtime parameters remain available separately | -| **Basic Synth / Piano / Clean Guitar** | Instrument-specific sound controls, advanced modulation/performance settings and audition keyboard; received keys/pedals are distinct from sounding/releasing voices | -| **OpenStudio Drums** | Sixteen audition pads, eight piece inspectors/outputs, complete 128-note remapping and Ignore, with pad labels derived from effective native mappings | +| **Basic Synth / Piano / Clean Guitar** | Compact sound/performance subpages, wide Overview, persistent output/audition controls; received keys/pedals remain distinct from sounding/releasing voices | +| **OpenStudio Drums** | Core kit and Cymbals & extras compact pad views, named selected-piece inspector, eight output groups, wide Overview and complete 128-note remapping/Ignore | + +**Find a control:** use **Find** in the plugin toolbar to search native parameter names, modes or named workflows such as Modulation matrix, Drum map, EQ Match and Response analysis. Selecting a result opens its page and moves focus to the control or workflow entry. Find preserves the current sound: it does not switch a model or enable an effect. If a setting is inactive in the current configuration, it explains that its stored value is retained. Hidden machine memories and compatibility aliases are not listed as duplicate controls. + +**Window size:** compact editors prioritize the main sound controls and use tabs or expandable details for secondary settings. Synth Voice separates Oscillators, Filter and Amplifier; Modulation separates Matrix, LFO and Macros. Piano Performance separates Touch and Pedals. Guitar Play separates Sound, Articulation and Performance; Drums separates Pads, Selected piece and Character. Instrument output and audition controls stay outside the scrolling detail area. A wide, tall instrument host offers Overview with those existing sections together; smaller hosts return an active Overview to the main page. Dense details can scroll, and individual pages remain available. Large Reverb windows expose additional machine sections and manual Response analysis. These choices follow the plugin window's size, including display scaling, rather than the screen's advertised resolution. The suite's native minimum of 640x480 describes the outer window; the frame and title bar reduce the space available to its editor. + +**Inactive settings:** explanatory disabled controls or contextual pages retain saved values for other models and modes. For example, Articulated Drums retains the Legacy-only Kit and Hi-hat Tightness settings; Guitar's harmonic and slide controls apply to their relevant articulations. Gate disables Hold/Hysteresis in downward expansion, and Limiter explains Classic/Edge restrictions. Incoming MIDI, actual sounding voices and measured audio levels remain separate readouts; unavailable data is not replaced by simulated activity. Vintage hall/random retain Build-up (0-300 ms) and independent Input diffusion. Build-up is the summed delay of four input allpass stages before the tank, not an exact acoustic attack time. Zero preserves the original input; Input diffusion zero gives a simple added delay and higher values distribute the excitation. Tank Diffusion stays separate. Both types remember their controls independently, including presets, Compare and Undo. Drag a knob vertically, hold **Shift** for fine adjustment, or double-click to reset it. Click a value to enter a number; **Enter** commits and **Escape** cancels the entry. Normalized controls display percentages while retaining their native stored values. Focused knobs support arrow keys, Page Up/Down, Home and End. **Undo** and **Redo** apply to editor parameter gestures; an EQ node drag changes frequency and gain in one gesture. Select an EQ band by its numbered button to edit or enable it. -The other free editors now share preset/history/A/B controls and keep their main surface within the window. Detailed controls use tabs. A/B compares complete native configurations, including hidden engine settings, compressor/reverb memories and embedded IRs. Preset, Default, Compare and IR changes share editor Undo/Redo. Comparison slots last for the editor session. Editors follow their processor identity through chain reordering and reject writes after removal. Plugin options provides complete `.ospreset` import/export and the saved processor-bypass setting. A preset from another processor type is rejected. The toolbar Host bypass controls the actual FX slot and synchronizes with the chain. It is separate from the processor state stored in presets and Compare, and supports editor Undo/Redo and main-window project history. +The other free editors share preset/history/A/B controls. A continuous knob or slider gesture creates one editor Undo step; discrete choices use the same native edit path. A/B compares complete native configurations, including hidden engine settings, compressor/reverb memories and embedded IRs. The copy button follows the active slot: **A → B** when A is active, **B → A** when B is active. Preset, Default, Compare and IR changes share editor Undo/Redo. Comparison slots last for the editor session. Editors follow their processor identity through chain reordering and reject writes after removal. Plugin options provides complete `.ospreset` import/export and the saved processor-bypass setting. A preset from another processor type is rejected. The toolbar Host bypass controls the actual FX slot and synchronizes with the chain. It is separate from the processor state stored in presets and Compare, and supports editor Undo/Redo and main-window project history. **Original audio stages (development):** Compressor and Preamp offer Audio stages > Original stages, with Legacy as the default for both new and older state. Compressor applies separate input/output colour around gain reduction in FET, Tube Opto, Solid State Opto, Bus VCA and Punch VCA. FET Ratio Off retains these stages; Clean and Legacy models use a linear path. Headroom offset shifts the digital operating level, not interface dBu calibration. Compressor reserves the prepared 16x filter delay in addition to its 20 ms path, including the aligned dry signal. Preamp keeps the existing 4x path in Legacy; Original stages uses a prepared 16x FIR path with separately reported latency. It places input colour before Tone EQ, Output drive after it, and linear Output trim last. Audio character is a configuration change with one Undo step; Headroom and Preamp Output drive support automation. These original level- and history-dependent algorithms are not fitted commercial circuit models. Original Preamp reports 78 samples of filter delay; at 48 kHz Original Compressor reports 1038 samples in total. Original stages use more CPU than Legacy. Aggressive combined drive/headroom settings can alias; listening acceptance and reference fidelity remain open. @@ -1338,9 +1401,37 @@ Limiter Response adds Crest (crest-adaptive recovery), Bus (soft-knee RMS compre Limiter Quality offers Off (1x), 2x, 4x, 8x, 16x and 32x audio processing. The limiter runs at the selected higher rate, then protects and meters the downsampled output. Quality changes restart processing history and update host delay compensation; they are saved in projects, presets and Compare but are not automatable. Old projects default to Off. At 48 kHz the total delays are 960, 1078, 1093, 1099, 1103 and 1104 samples respectively. Higher factors use more CPU; 16x/32x are primarily useful for offline evaluation. Oversampling does not establish better audible quality or guarantee every possible reconstructed peak. +#### Signal compatibility and corrected processing (development) + +**Signal path:** EQ, Preamp, Saturator, Chorus, realtime Pitch Correct, Basic Synth, Piano, Clean Guitar and Drums offer **Plugin options > Signal path > Legacy / Corrected**. New instances and factory reset select Corrected; older complete states and named numeric snapshots select Legacy. The choice is saved with the plugin and included in Undo and Compare. Change it while stopped: changing paths resets processing history. It is independent of each instrument's voice engine and the effects' audio-character controls. NAM Rack retains its existing signal paths. + +Corrected preserves floating-point EQ headroom, makes Foldback symmetric, uses absolute C-B note switches only for Custom pitch scales, and supports the configured low-note pitch range at higher sample rates through anti-aliased analysis. It also uses continuous fractional instrument/modulation phases, maps Synth/Guitar tone poles to their 48 kHz reference, makes Preamp's internal bypass transparent after its reported latency, and commits Saturator type changes at the fade endpoint. Corrected pitch analysis uses an anti-aliased 22.05-32 kHz analysis stream and a 2048-sample window (about 64-93 ms). Work is spread over the next analysis hop (up to approximately 23 ms at 44.1 kHz). Low-note support therefore adds detector decision time; the audio stream's reported alignment latency is unchanged. All tested 48 kHz pitch workloads met their deadlines on the audit machine; repeated 192 kHz small-buffer probes recorded occasional single-instance misses and more frequent four-instance misses. Exact listening acceptance remains pending. + +Drum All Notes Off now lets one-shot decays finish; All Sound Off remains immediate. Automatic export tails include drum decay, chorus feedback, filter ringing and manual delay support. These declarations are conservative and can add silence. In the Articulated drum engine, **Kit** and **Hi-hat Tightness** are disabled because that engine supplies its own voices and hat response; those controls remain available in the Legacy voice engine. This voice-engine choice is separate from Signal path. + #### Reverb and convolution -Standalone Reverb also shows current-block L/R input and output sample peaks in dBFS. The output reading includes wet/dry mix, Send and ducking; mono is shown in both columns. Silence or unavailable data appears as a dash, and readings at or above 0 dBFS are highlighted. Current shows the latest block; Maximum shows native maxima retained across blocks until Reset peaks, processor reset or prepare. A 0 dBFS reached indicator stays visible after a brief full-scale event. Reset peaks acts on the next audio block, with pending status if processing is stopped. This telemetry action does not change audio, saved state or Undo. These are sample peaks, not true-peak or loudness measurements. Short windows keep the labelled numeric output controls visible while hiding their duplicate knobs. +**Reverb Output (development):** The standalone Reverb footer has an **Output** control from -60 to +12 dB, default 0 dB. It changes the complete plugin output after the wet/dry mix and ducking, including retained tails and convolution cross outputs. Output remains editable in Send mode and keeps its value when switching reverb types. Projects, presets, Compare, portable preset files and editor Undo/Redo retain it; older saved states open at 0 dB. Lock balance continues to preserve the wet/dry routing fields when recalling presets; presets can restore their own Output value. This is an unreleased source-checkout addition. + +**Return level (development):** All 41 types show **Original / Calibrated** on **Setup**. Calibrated applies fixed wet-return trims to current algorithmic voices; the compatibility Legacy Room/Hall/Chamber/Plate engines and custom IRs retain their levels. Both Legacy and Dispersive Spring receive calibration. Wet, Dry and Output remain independent. Return level is also independent of **Processing: Original / Refined**: Original processing can use a Calibrated return. The shared choice stays editable in Send mode and is retained by projects, presets, Compare, portable files and editor Undo/Redo. New Reverb instances use Calibrated. Older saved states without this choice restore Original, preserving their previous return level. Factory reset chooses Calibrated. These fixed trims do not guarantee equal perceived loudness across settings or source material; Vowel hall remains particularly source-dependent, and audition is required. This is unshipped source-checkout behavior. + +**Controls (development):** Setup offers **Legacy / Corrected**, independently of Processing and Return level. Corrected ramps dedicated-engine Wet/Dry changes over 20 ms, bounds Spatial modulation by each delay's available range, and preserves 48 kHz damping time constants at other sample rates in Studio/Plate Legacy tone, Clear and Shimmer/Nonlinear. Tone changes are smoothed; explicit filter bypass remains exact. Vintage/Retro era processing retains its intentional rate conversion. New standalone instances, factory reset and the Voiced presets use Corrected; older states without this choice and the eight historical presets use Legacy. Projects, Compare, portable presets and Undo/Redo retain the choice. This does not change historical Legacy-engine Wet/Early routing: Early can remain audible at Wet zero in those engines. NAM Rack is unchanged. These are unshipped control corrections, not listening acceptance. + +**Voiced preset headroom and Shimmer (development):** The 41 individual Voiced presets use fixed Output trims (0 to -13 dB), designed for about 3 dB of sample-peak headroom on the shared guitar reference at Dry 70% / Wet 100% (input peak -1.09 dBFS). Output scales dry and wet together; raising it does not increase the reverb-to-dry ratio. The all-bank collection uses one shared -14 dB Output trim, because Output is global. Adjust Output to suit the track and downstream headroom. These are fixed authored choices, not automatic loudness matching or a guarantee against clipping on other material, boosted input or held accumulation. Saved states and the eight older factory presets retain their levels. + +The new **Factory - Voiced Shimmer** uses Input routing: octave/fifth voices feed the long base tank once. Tank and Input + Tank remain available for recirculating pitch, whose extra loop losses shorten decay. Decay is nominal, not a measured broadband RT60; damping and routing matter. Input/Both activate the input pitch pair and can cost more than Tank. Processing > Original remains available when the intentionally colored Refined Plate/Cloud readout is unwanted. + +**Factory voices (development):** In **Plugin options**, **Load voice preset** recalls the selected type's complete voiced preset. **Load voiced collection** installs all authored banks for browsing. Both actions replace plugin settings and support Undo; changing Type alone keeps edits. The Preset dropdown also offers `Factory - Voiced ` for each of the 41 types, with authored timing, tone, density and family controls. **Factory - Voiced collection** installs those profiles into all type memories at once, then selects Room; browse the type selector to compare them. Width, Wet, Dry, Output and Processing are shared rather than per-type, so the collection uses a common balance/width and Refined processing. Individual voices also recall their own shared settings. The eight earlier presets, including Tight room, Long dark hall and Shimmer halo, retain their settings. Recall replaces complete plugin state as one undoable change. Save a new name or export current settings to keep edits. Generated entries cannot be overwritten or deleted; an existing user preset with the same name keeps priority. Lock balance preserves routing balance during recall, while Output, Return level and other shared character choices follow the preset. Constructor defaults and older projects retain their settings. These unshipped voices still require listening and are not loudness matched. + +**Contextual controls:** Shaped reflections displays Duration in milliseconds (25-2000 ms), with an early-envelope caption; storage and automation still use the original normalized value. Shimmer Amount double-click resets to the Shimmer bank's 50% starting value. Tone help shows effective Vintage/Retro era bandwidth limits. Bloom help explains that Length has no effect at zero Diffusion. Shimmer's Decay remains a nominal tank setting: Amount and Tank/Both pitch routing can shorten the actual tail; the Voiced preset uses Input routing and a fixed Output trim; this does not make the knob a measured broadband RT60. + +**Shimmer pitch scheduling (development):** Spectral Shimmer offers **Original / Spread** on Character. Spread divides spectral work, including FFT stages, and staggers the tank/input pitch roles. It retains the 120 ms analysis window and 30 ms hop, adding one hop to the pitched path while dry timing stays unchanged. This is an explicit saved choice, independent of Refined processing. New instances and older states without it use Original; the Voiced Shimmer and collection presets select Spread. A live scheduling change primes and warms the prepared alternate bank before a 20 ms fade, temporarily running both banks. In steady Spread/Input routing, the unused tank pitch bank sleeps. Selecting Tank/Both refills it with current audio for about 300 ms before the usual 50 ms route fade; the Input path remains audible while it warms, and an interrupted change can cancel the wakeup. Original-only routing retains its earlier behavior. Prepared memory remains allocated, and Both routing still processes both stages. High sample rates, many instances and live transitions involving Original can exceed callback budgets; see the dated measurements in [audio qualification limits](testing.md#retained-audio-qualification-and-limits). Device performance must be qualified on the actual setup. + +**Vowel broadband body (development):** Vowel hall adds **Broadband body** on Character. It mixes a wet bed beneath the existing vowel resonances, bypassing the formants while retaining common cuts and Width, helping material with little energy at the formant frequencies. Zero preserves the formant-only response; dry audio and feedback are unchanged. The control ramps over 20 ms and is stored with projects/presets/Compare. New instances and older states without it use zero; the Voiced Vowel and collection presets use a body contribution. It changes tonal balance and does not guarantee perceived loudness on every source. + +**Processing (development):** Applicable types show **Original / Refined** on the Character page. Original remains the default and is restored when older saved states lack this choice. Refined adds a short, signed wet-output diffusion stage with frequency coloration to Studio Plate and Cloud field; it uses settled integer tank taps when motion is off in Drifting, Clear and supported digital spaces. The control appears only when the selected engine uses it; Legacy/Modal Plate and Shaped reflections retain their own processing. Processing is shared across types and stored in projects, presets, Compare and portable preset files, with editor Undo/Redo. The new Bright short plate, Modulated cloud and Grand gallery factory voices explicitly select Refined. This is an unreleased opt-in change; audible improvements and loudness matching remain unqualified. + +Standalone Reverb also shows current-block L/R input and output sample peaks in dBFS. The output reading includes wet/dry mix, Send, ducking and Output trim; mono is shown in both columns. Silence or unavailable data appears as a dash, and readings at or above 0 dBFS are highlighted. Current shows the latest block; Maximum shows native maxima retained across blocks until Reset peaks, processor reset or prepare. A 0 dBFS reached indicator stays visible after a brief full-scale event. Reset peaks acts on the next audio block, with pending status if processing is stopped. This telemetry action does not change audio, saved state or Undo. These are sample peaks, not true-peak or loudness measurements. Short windows keep the labelled numeric output controls visible while hiding their duplicate knobs. **Reverb:** Room, Hall, Plate, Chamber, Spring, Shimmer, Nonlinear, Convolution, Church, Ambience, Vintage hall and Vintage random remember separate settings. Church has a larger sixteen-line network with dense scattering, long reflections and a 6 s default decay; Ambience emphasizes short early reflections over a quiet, sparse late field with a 0.35 s default decay. These two types always use their dedicated networks; they have no Legacy selector. @@ -1348,6 +1439,8 @@ Reverb peak measurement defaults to Sample. Choose 8x peak for an optional finit Nonlinear Hold circulates stored excitation through the current early envelope and sustains the independent late network. Freeze excludes new input; Infinite adds new diffused input. Duration and Shape edits wait for release, and modulation pauses. Output tone and Late level remain active. The early pattern repeats over Duration; this is an original hold mechanism. Releasing Hold restores ordinary feedback and decay; Spillover releases outgoing Hold before draining. +**Hold headroom (development):** Infinite keeps admitting new input to a sustained tank; Freeze stops new held excitation. Earlier continuing-input tests reached safety clamps, especially maximum Nonlinear with a Calibrated return. The October 22 implementation adds smooth buildup handling to Nonlinear, Dispersive Spring and Rising while retaining these distinct Hold input policies. All 16 bounded headroom fixtures pass on the final local Release build without final safety-clamp hits. This does not establish indefinite stability, every possible input or listening acceptance. Reduce incoming level or excitation duration when needed; Wet or Output attenuation lowers the final level but cannot restore an already saturated tank. Return calibration changes wet readout gain rather than feedback. + Nonlinear reverb has a Motion tab. Modulation moves both the reflection taps and the late reverb delays, with up to 2 ms peak movement at full depth. Mod rate controls both stages from 0.05 to 8 Hz. The default zero depth retains fixed delays. This is an original modulation design; the timing graph still shows only the early amplitude envelope. Reverb adds Drifting hall, Drifting chamber and Drifting diffuse. These original spaces use separate long, short and medium-delay networks. Tape feedback adds Drive and frequency emphasis around feedback saturation; zero Drive retains linear feedback. Motion combines irregular Wow and faster Flutter with a shared rate control. Tone includes independent Bass decay and crossover. Each type remembers its settings. Freeze excludes new excitation, Infinite admits it, and both suspend saturation and moving delays until released. Spillover retains the outgoing settings and lets the old tail decay when changing types. The existing Vintage hall/random stay unchanged. The new type selector preserves old automation mappings. These are original algorithmic spaces, not calibrated reproductions of commercial reverb or tape hardware; the timing guide remains nominal. @@ -1510,7 +1603,7 @@ The audition keys and pads use separate instrument voices in the live monitor/ma **Guitar plucked loop and chorus (development):** String > Engine selects Legacy additive synthesis or Plucked loop for new notes. Nominal decay spans 0.2-8 s before damping and palm-mute losses. Pick position and hardness shape each new excitation; pickup position, damping, Body and palm mute stay live. Body adds two original resonances, not a measured guitar body. Existing string-channel routing and bends apply to both engines. Chorus adds an independent stereo delay modulation (12 ms base, 0-6 ms depth, 0.05-5 Hz); Stereo motion remains the earlier pan modulation. Old states retain Legacy and zero delay-chorus mix. Undo, Compare, presets/projects and isolated keyboard audition include all controls. Prepared string memory is about 2.5 MB at 48 kHz and 9.8 MB at 192 kHz. Sampled instruments, coupled strings, hardware qualification and listening acceptance remain open. -**Independent plugin outputs (development, October 3):** Drums > Piece > Piece output assigns each of eight pieces exclusively to Main 1/2 or an auxiliary stereo pair from 3/4 through 17/18. Select a pad or Drum piece first. In Track routing, create a send to a destination track and choose the matching Source channels. The destination can have its own FX and stem render. An unassigned pair is silent. Following stereo FX process the main pair while retaining auxiliaries; track gain, pan and pre/post-fader send choices apply to their selected pair. Old states restore every piece to Main 1/2. Project save/reopen, full plugin state, Undo and Compare retain routing. Isolated keyboard/pad audition intentionally folds the audition clone back to stereo without changing project routing. Stereo Freeze rejects tracks with enabled auxiliary sends; render the destination buses as stems instead. +**Independent plugin outputs (development, October 3):** Drums > Kit > Selected piece > Output, or the Outputs page, assigns each of eight pieces exclusively to Main 1/2 or an auxiliary stereo pair from 3/4 through 17/18. Select a pad or Drum piece first. In Track routing, create a send to a destination track and choose the matching Source channels. The destination can have its own FX and stem render. An unassigned pair is silent. Following stereo FX process the main pair while retaining auxiliaries; track gain, pan and pre/post-fader send choices apply to their selected pair. Old states restore every piece to Main 1/2. Project save/reopen, full plugin state, Undo and Compare retain routing. Isolated keyboard/pad audition intentionally folds the audition clone back to stereo without changing project routing. Stereo Freeze rejects tracks with enabled auxiliary sends; render the destination buses as stems instead. **Drums piece controls and mapping (development):** Select a pad or Drum piece to edit its level, tuning and pan offset. Tuning spans +/-12 semitones for pitched components (including kick sweep); noise is unchanged. Pan offset is added to the existing piece position, clamped left/right, then scaled by Width. Tune/pan transitions take 20 ms. The mapping guide reports the native MIDI input-to-voice translation for the selected piece and current GM/Roland TD subset. It also identifies same-channel hat closure and supported poly-pressure chokes. Other notes retain synthesized fallback percussion. Pointer and Enter audition select the piece. All pieces survive Undo, Compare, automation and project/preset recall; old states default tune/pan to zero. The historical Velocity Curve is preserved: positive values soften response, negative values harden it, and zero retains exponent 1.135. These are synthesized drums; recorded articulations and sampled room ambience are not included. Custom input maps are described below. @@ -1639,6 +1732,91 @@ If a project with heavy or problematic plugins is slow to load, open it in Safe **OpenStudio NAM Rack** is the built-in Guitar/Bass workspace for NAM A1/A2 pedal, amp, and full-rig captures. +**Multicore monitoring, development checkout:** independent audio tracks whose +FX chains contain only NAM Rack can share work across CPU cores without adding +another audio buffer. Each Rack keeps its effect order, tone settings and +oversampling. At least two independent NAM tracks are needed. A track with +another effect type, MIDI/instrument processing, a send or a sidechain retains +serial processing; unrelated NAM tracks can still share work across cores. +There is no four-track limit, but available cores do not guarantee stability +at very small buffers. + +If NAM Rack shows **Overload**, track processing recently missed a deadline or +skipped work, even if the CPU or callback meter looks low. Use **Buffer** to +open Audio Settings, try the next larger supported buffer and disable unused +effects if needed. Audio Settings reports the same condition. Raw recording +is kept separate from this monitoring overload recovery, but check the saved +recording as well as what you hear. The four-track/16-sample target and the +reported eight-sample crackling remain under qualification; see the +[NAM performance guide](nam-rack.md#four-track-live-monitoring-acceptance-target). + +**Output peaks reduced recently** means NAM Rack softened loud output peaks. +Lower **Output** to reduce that compression. After three seconds without new +activity, the message becomes neutral **Earlier output peaks were reduced** +history. It does not mean the CPU ran out of time; **Overload** reports that +separate problem. Emergency safety muting retains its own explanation. + +**Octaver Fuzz, development checkout, October 23, 2026:** the wider olive +pedal has Down, Up and Direct octave controls, plus Fuzz, Tone and Level. +Choose Vintage (asymmetric, dynamic clipping), Sustain (symmetric clipping +with a scooped tone), or Gated (biased clipping with an envelope gate). +Separate **Octave** and **Fuzz** switches allow either section to work alone. +Fuzz processes the octave/direct mix before PRE EQ. Direct is the unshifted +octave input and passes through fuzz when enabled. Turn **Clean** on the pedal, +beside **Voice**, to blend that incoming Direct/Down/Up mixture with the fuzz +sound. The same parameter remains available in **Device Controls**. Focus the +knob and press Enter, or right-click it, for exact percentage entry. Escape +cancels the entry; double-click resets the blend. Arrow keys, fine adjustment +and Undo are supported. While Fuzz is off, the blend is saved for when you +engage it. + +**Level** controls the entire blend before PRE EQ → Drive → Distortion → +Pedal Capture → Amp; increasing it pushes the following gain stages harder. +At 100% Clean and 0 dB Level, Fuzz passes its incoming mixture unchanged. +Clean therefore retains any active octave voices; it does not bypass the later +drive stages. The header's **OS / DRIVE** setting applies to Precision Drive +and Distortion across all NAM Rack instances, not to Fuzz or NAM captures. + +Vintage and Sustain now offer less gain in the lower and middle part of Fuzz's +travel, retaining their full-gain endpoint. This reduces amplification of quiet +input noise at moderate settings; it also reduces late-note sustain at those +settings. Gated retains its previous gain range. For a sustained, scooped fuzz +in the direction of the Swollen Pickle reference, start with **Sustain** and +adjust Fuzz and Tone through your amp and cabinet. These are original voices, +not a model of that pedal's circuit or matching knob positions. + +When stacking gain stages, set Fuzz **Level** for how hard it should drive the +next pedal. The rack's **Gate** detects the clean input and attenuates the signal +after the pedals and NAM models, before cabinet and time effects. Set its +threshold above the measured idle input noise, then adjust **Release** in +Device Controls so quiet notes decay as intended. A threshold below the noise +floor leaves the gate open. Fuzz does not add a hidden noise gate; combining +high Fuzz, Drive and Distortion gains can still amplify hiss strongly. + +Sustain retains a 15% low-frequency contribution at the bright end of Tone. +It also receives fixed level makeup around the centre of Tone, tapering to +zero at both ends; the amount follows Fuzz and Tone. A shared trim reserves +headroom in all three wet voices before Clean blend, preserving the clean +contribution. Voice levels still depend on the source material and Tone +position, so use Level when comparing sounds, especially at the Tone extremes. +Combining Direct, Down and Up applies an internal headroom pad only to the +fuzz clipping cells and sag response. The pad follows the octave mix controls; +the clean contribution and gate detector keep the incoming mix level. Octave +tracking still happens before fuzz. + +In **Bass** mode, Gated uses a separate detector with 1 ms attack/120 ms release, +a closing threshold 6 dB below its opening threshold, a 65 ms hold, and a +3 ms attack/30 ms release for the gate gain. Guitar retains its previous gate +behavior and sag timing. These changes use the existing controls. + +Fuzz defaults off for older projects, presets and Compare snapshots; bypass +preserves the exact incoming octave/direct mix. The same parameter IDs and +controls are retained in project/preset recall, A/B Compare and Undo. Saved +active fuzz tones from earlier development builds may sound different with +the revised processing. These are unreleased original fuzz algorithms rather +than measured replicas of commercial pedals; listening quality remains +unconfirmed until user audition. + 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 @@ -1669,6 +1847,24 @@ 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. +**Reviewing an older preset's instrument profile, development checkout, +October 22, 2026:** open **Preset Library** from the current preset name. A +**Profile review** collection appears when a preset's instrument label disagrees +with its saved Guitar/Bass profile. It is available in either instrument mode; +each affected row shows both the label and the saved sound. Loading the original +uses its saved profile. + +If the label was intended, select the preset and choose **Create Bass copy…** +or **Create Guitar copy…** in its details or its row's **Actions** menu. The +Actions menu remains available in compact windows. Review the explanation, +choose a unique name, and confirm **Create copy**. This saves an independent +library copy with the chosen active profile and matching label, preserving the +other saved settings. Changing profile changes voicing and EQ frequencies; +it is not a guarantee of the intended tone. The original preset and current rack +remain unchanged, and the copy is not loaded automatically. Select its instrument +mode in the rack, find the new preset, and load it when ready to compare by ear. +Existing names are not overwritten; a failed copy is reported in the library. + 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 @@ -2470,8 +2666,8 @@ platform keys without this portable notation. |-----------------------|-------------------------| | Play / Stop | `Space` | | Stop | No separate default | -| Record | `Ctrl+R` | -| Go to Start | `Home` | +| Record / Finish Recording | `R` (also `Ctrl+R`, numpad `*`) | +| Go to Absolute Start | `1` / `Home` (seek to zero and reveal beginning) | | Toggle Loop | `L` | | Toggle Metronome Enable | `K` | | Start / Stop Click-Only Metronome | `Ctrl+Shift+Space` (macOS: `Cmd+Shift+Space`) | @@ -2509,6 +2705,7 @@ platform keys without this portable notation. | Group Selected Clips | `Ctrl+G` | | Ungroup Selected Clips | `Ctrl+Shift+G` | | Toggle Clip Mute | `U` | +| Duplicate Selected Clips | `Ctrl+D` (shared group offset) | | Nudge Left | `Left` | | Nudge Right | `Right` | | Nudge Left (Fine) | `Ctrl+Left` | @@ -2523,6 +2720,10 @@ platform keys without this portable notation. | Mute Tool | `X` | | Smart Tool | `Y` | +Pitch Editor Select also uses `V`. The selected track's Record Arm command +uses `Ctrl+Shift+R` in track controls; Piano Roll Select Range uses the same +combination in its own editor scope. `R` is reserved for global Record. + ### 16.5 Insert | Action | Shortcut | @@ -2546,8 +2747,8 @@ platform keys without this portable notation. | Help Reference | `F1` | | Keyboard, Mouse & Trackpad | Help menu | | Zoom to Time Selection | `Ctrl+Shift+E` | -| Zoom In | `Ctrl++` | -| Zoom Out | `Ctrl+-` | +| Zoom In | `+` / `=` / numpad `+` (also `Ctrl++`) | +| Zoom Out | `-` / numpad `-` (also `Ctrl+-`) | | Zoom to Fit | `Ctrl+0` | | Save Screenset 1 | `Ctrl+Shift+1` | | Save Screenset 2 | `Ctrl+Shift+2` | @@ -2613,7 +2814,9 @@ platform keys without this portable notation. | 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 | +| Set project/loop range | Plain horizontal drag or Primary+Drag ruler | +| Set/extend edit time selection / fit zoom range | Shift+Drag / Alt/Option+Drag ruler | +| Zoom time around drag start | Plain vertical ruler drag (up: out, down: in) | | Context menu | Right-click | --- @@ -2652,6 +2855,12 @@ platform keys without this portable notation. 4. Close unnecessary background applications. 5. Check your audio interface drivers are up to date. +In the development checkout, NAM Rack's **Overload** warning can appear even +when its callback meter is low: processing may have skipped track work to +contain a missed deadline. Audio Settings distinguishes this from device-path +interruptions and callback overruns. Increase the buffer and retest the same +tracks; a low average CPU reading alone does not establish dropout-free audio. + ### 17.4 Plugin Not Showing Up **Symptoms**: A VST3 plugin you installed is not appearing in the plugin list. diff --git a/docs/free-plugins.md b/docs/free-plugins.md index db61826..0346d99 100644 --- a/docs/free-plugins.md +++ b/docs/free-plugins.md @@ -4,17 +4,186 @@ This is the maintenance guide for the sixteen non-NAM processors in the developm ## Product and design contract -Fifteen effects/instruments have dedicated editors. Reverb, Delay and Chorus use the revised approved compositions. Pitch Correct opens the existing clip **Edit Pitch** session, including its docked/detached view and history; its realtime FX settings remain a separate compatible processing path. +The October 22 Design 03 layouts are implemented in the working tree for the dedicated non-NAM editors. [Production qualification](testing.md#design-03-production-integration-22-october-2026) records automated results and the remaining device, platform and listening gates. The editors use the existing native parameters, complete-state bridge and history owners. This development work is not a claim about a published installer or final visual/listening acceptance. Pitch Correct retains the existing clip **Edit Pitch** session, including its docked/detached view and history; its realtime FX settings remain a separate compatible processing path. - Keep processors independently insertable and reorderable. A family selector changes the engine inside that processor, not another processor's saved identity. - Show the main musical decisions first, with contextual detail pages for controls the selected engine actually consumes. Do not expose inactive controls merely because a descriptor exists. -- Keep type, processing character and preset separate. Hide an inapplicable control without deleting its saved value. Closed detail pages indicate non-default or active settings. -- Use the shared theme and original artwork. The primary surface fits its host; detailed pages may scroll. Native minimum client size is 640x480 for this suite; NAM and hosted editors retain their own rules. +- Keep type, processing character and preset separate. Hide or disable an inapplicable control with an explanation, without deleting its saved value. Detail pages expose non-default or active state where supported. +- Use the shared theme and original artwork. Group controls beside proportionate plots; bound graph height and scale controls for readability instead of stretching blank panels. Respond to the host's available width and height, including short wide windows. The primary surface fits its host; detailed pages may scroll and retain explicit open/close choices. The native outer-window minimum is 640x480 for this suite; its usable client area is smaller because of the window frame and title bar. NAM and hosted editors retain their own rules. - Support numeric entry, fine drag, reset, keyboard and profile-aware wheel input. One continuous gesture or compound edit must produce one Undo entry and correctly paired automation gestures. - Metering and performance displays must come from native telemetry. Distinguish received MIDI keys from sounding/releasing voices, and show idle/unavailable states explicitly. - Require no sample pack or download. Convolution has one original default IR; user IRs travel in complete state. Do not ship proprietary libraries, reference artwork or vendor branding. -The approved prototypes and repeated review screenshots were development aids. Their simulated telemetry, example presets and fake IR states are not production code. Superseded previews and raw runs belong under ignored `output/`, not in the source tree. +Prototypes and repeated review screenshots are development aids. Their simulated controls and example states are not production code or native qualification. Superseded previews and raw runs belong under ignored `output/`, not in the source tree. A feature inventory or a passing geometry check does not establish that an editor is complete, usable or visually approved. + +## Editor maintenance and qualification + +The editors prioritize a clear main task, readable controls at every supported +size, fast exact adjustment, trustworthy comparison and visible native feedback. +The working-tree implementation uses +matte controls, reduced toolbar chrome, primary controls beside native meters, +compact subpages and host-sized overviews. Further review and qualification use +the gates below; implementation is not final visual approval. + +**Implemented navigation and interaction:** the shared **Find** searches canonical +native controls and named workflows, opens the owning page and focuses the +result. It does not change model, mode or audio settings to make an inactive +control usable; those results explain that their stored value is retained. Saved +machine banks and compatibility aliases are not duplicate editable search +results. Numeric entry, fine drag, keyboard/profile-aware wheel gestures and +reset use the production parameter/history path. A/B captures complete native +state; its copy button follows the active bank. Host bypass remains separate +from saved processor bypass. + +**Implemented responsive workspaces:** instrument output and audition controls +stay outside the scrolling detail body. Synth Voice and Modulation, Piano +Performance, Guitar Play and the Drums kit use compact subpages. At sufficiently +wide and tall host sizes the instruments offer **Overview**, showing the same +existing sections and parameter identities together; reducing the host returns +an active Overview to the main page. Dense detail/overview content can scroll. +Effects retain their own graph, meter and contextual-control layouts; wide +Reverb exposes additional machine sections and its manually requested response +analysis. These are host dimensions, not monitor-resolution detection. + +**Reverb setup:** **Setup** holds the shared **Controls** and **Return level** +policies; **Processing** remains with the applicable machine's Character +controls. **Plugin options** contains **Load voice preset** and **Load voiced +collection**. Type browsing retains each bank's edits, whereas those actions +recall complete settings. Wet/Dry/Output and native input/output/ducking readouts +remain directly reachable; Metering and Response retain their separate tools. + +The October 22 source audit mapped **2,505 native parameter descriptors** across the sixteen +non-NAM processors, plus **49 convolution complete-state shape fields**. The +descriptor total includes 129 compatibility aliases and 920 saved machine-memory +entries; it is not a count of controls to display simultaneously. The review's +ignored feature inventory records native bounds, units, canonical aliases, +contextual pages and workflow entry points. It enumerates 2,378 pure Reverb +layout scenarios against `reverbControlLayout.ts`. This checks planned placement, +not browser rendering, DSP response or sound. Regenerate the inventory when the +native schema changes and separately verify every reachable surface. + +### Feature retention and discoverability + +| Area | Implemented surface and retained workflow to qualify | +| --- | --- | +| Shared shell | Preset browsing/save/modified state, complete-state import/export, Undo/Redo, active-bank A/B and correctly directed copy, host versus saved DSP bypass, and Signal path where implemented. Find-control search reveals a named page and explains applicability; it must not change the sound simply to reveal a result. | +| EQ | Graph-first 24-band and group editing; Filter/Dynamics/Detector inspector; target/phase/quality/ordinary/spectral restrictions; analyzer views, references, peaks and frequency range; discoverable Draw, Match and Grab with audition/Apply/Cancel; inter-instance tools, preset/startup library and 32 prepared MIDI configurations. | +| Graphic EQ | All 10/31-band controls, independent saved banks, selected/group offsets, target/cuts and native response/analyzer. Flat bank affects the active bank; Reset selected affects its selected bands in one grouped UI edit, without adding a DSP parameter. | +| Gain Phase | Manual timing/polarity remains reachable beside a guided reference/group/capture/results workflow. Retain 2–8-instance independent/linked policies, related groups, timing/all-pass/spectral fit, sparse versus continuous capture, route policy, cancel, stale-result rejection, audition and whole-group Undo. Keep saved 48-point/channel phase curves discoverable. | +| Pitch Correct | Clearly distinguish realtime source/correction/MIDI-output settings from choosing recorded audio for the existing graphical editor. Preserve its dock/detach/history and active-realtime warning. No replacement pitch canvas or implied MIDI-target correction. | +| Compressor | Stable positions for applicable controls, with distinct FET/opto/VCA workflows; model/engine memories, VCA channel banks, external key, detector filtering, original stages/headroom, calibrated metering, Punch noise/monitor and Ratio Off/Infinity/Auto-release explanations. | +| Gate | Threshold/range and envelope beside input/reduction feedback; gate/expander distinction, filtered internal/external key, key audition and detector policy. Hold/Hysteresis/Transient response are contextual; Closed/Opening/Open/Holding/Closing/Expanding status comes from the native gate stage, with unavailable feedback explicit. | +| Limiter | Threshold/ceiling and gain reduction together; eight styles, linking/unity audition, quality/latency and distinct sample-peak/reconstructed-peak/loudness history/reset. Hide or explain Classic/Edge inactive controls while retaining values. | +| Preamp / Saturator | Input drive, output drive where applicable and final trim remain distinct. Preserve stepped tone/HPF, reference/headroom/Legacy/Original choices, eight Legacy saturation types and five colour engines, compensation, bias, quality and input/filter detail. Label estimated compensation honestly. | +| Reverb | Keep the existing searchable/category browser and all 41 contextual layouts, per-machine memories, voiced preset versus collection recall, return/processing/control policies, timing/capacity, ducking, metering and rendered Response tools. Clearly show engine-specific Hold input policy, deferred changes and spillover. Favourites would be a new UI feature. | +| Convolution | Source/draft/applied identity; cancellable preparation and failed-load recovery; Shape, Balance, Sources, Position, Damping, Bright, EQ, Decay and Audition pages. Retain four-path order, exclusive Main/aux routing, source blend, declared geometry, portable original bytes and all Apply/Revert constraints. Direct trim handles supplement exact numeric entry. | +| Delay / Chorus | Timing and main musical controls remain together. Preserve all Delay modes, Multi/Dual topology and second-engine controls, motion/custom wow, ducking and post-repeat diffusion. Preserve Chorus/Flanger/Phaser voices/stages, waveform, tone and effective Ensemble limits. Free/synced values and actual tempo source must be distinct. Optional Delay L/R edit-link needs an explicit offset/equal-value gesture contract. | +| Synth | Oscillators/filter/amplifier remain useful in compact mode. Retain eight matrix routes/28 destinations, independent filter envelope, LFO mode, four macro base/live values, CC/channel Learn and all lower/upper MPE member/master ranges. Use one matrix heading row and contextual route help. | +| Piano / Guitar | Prioritize tone/touch/articulation over decorative drawings. Preserve expressive pedals/releases, secondary bodies, guitar plucked engine, nine articulations/keyswitches/string assignment and separate original motion/delay chorus. Distinguish editable pick/pickup positions, static artwork, incoming keys and actual allocated/releasing voices. | +| Drums | Keep selected-piece editing beside useful pads at compact sizes. Preserve sixteen inputs/eight groups, 128-note mapping/Ignore/Learn, draft Apply/reset, articulation/choke behavior and exclusive Main/auxiliary output pairs through 17/18. Names outweigh MIDI numbers; ignored input is visibly silent. | + +The production surfaces use native jobs and telemetry where available. Browser +fixtures exercise the real React editors against native-exported schemas with a +recording bridge; they do not run audio, MIDI Learn, project routing or device +operations. An inventory entry or fixture interaction does not qualify those +host workflows. Never substitute a second independent prototype control state +for the production parameter's native state and automation identity. + +### Maintenance acceptance gates + +The shared interaction and family layouts are implemented. Changes to them must +retain these contracts; deterministic passes do not close the separate device, +performance and listening gates. + +| Area | Maintenance responsibility | Required evidence | +| --- | --- | --- | +| 1. Contract and correctness | Verify current native schemas and complete-state operations; reproduce reported UI/DSP defects; preserve identifiers, aliases and historical defaults. Separate signal-preserving repairs from audible changes. | Every descriptor/full-state field/workflow has an owner and reachable location or an explicit retained-memory/compatibility explanation. Regressions cover old project, preset, Compare, portable state, Undo and PDC before any schema/DSP version change. | +| 2. Common interaction and compact layout | Implement shared exact entry, fine/reset/keyboard/profile wheel input, one-step gesture Undo, immediate linked readouts, dynamic A/B copy, search-to-reveal, visible focus and active/inactive/modified detail state. Reduce chrome; keep primary controls reachable in short windows. | Real-browser behavior and geometry tests at 640×480, each native preferred size, common desktop sizes, 4K and 125/150/200% equivalents. Test dense pages, open details, all modes, focus order, overlap and child clipping; a scroll-height pass alone is insufficient. | +| 3. Family workspaces | Migrate one coherent family at a time using the table above. Reuse existing native jobs and history owners; contextual pages retain every implemented advanced workflow. Give displays an editing/feedback purpose and keep all signal feedback truthful. | Per-family parameter/workflow coverage, inactive-value recall, page/selection persistence, cancel/error/retry and stale-instance tests. Connected Debug and Release WebViews must reach boot-ready, including detached windows and repeated close/reopen. | +| 4. Measured CPU work | Profile Release callbacks by engine, configuration, rate, buffer, channel/voice/instance count and transitions; include UI-open/closed runs. Prioritize measured deadline overruns, callback allocations/locks and redundant unchanged configuration work. Prepare expensive state off-thread and safely retire it. | Retain source/binary hashes, callback percentiles/maxima/deadline counts, CPU/memory, configuration and actual device context. Demonstrate unchanged deterministic output/state/latency where claiming a transparent optimization; otherwise use an opt-in compatibility-preserving policy and exact-artifact audition. | +| 5. Audio and release qualification | Execute baseline, adversarial parameter/state, render, performance, device and listening matrices below. Build current frontend assets and Debug app for the standard `python build.py dev --run` handoff. | Record `pass`, `fail`, `diagnostic_only` and `not_asserted` separately. No unresolved deterministic regression is waived as subjective; no metric or screenshot certifies tone. User audition of identified artifacts and physical-device/platform qualification remain separate gates. | + +Production implementation reuses typed native descriptors and bridge/history +contracts rather than introducing parallel parameter stores or unchecked casts. +Use Tailwind for ordinary layout and component-owned CSS for hardware artwork +and scene geometry, with one owner per rule, typed runtime geometry, container +queries and fluid sizing. Preserve subscribed meter leaves, hidden-view polling +cleanup and instance/epoch guards. Do not copy runtime stylesheet strings, +`!important`, decorative fake telemetry or prototype-only shortcuts into the app. + +### DSP and performance qualification matrix + +Use manufacturer documentation for observable responsibilities and musical +context, not identical knob positions or an undocumented circuit model. Existing +research links above and named regression fixtures are the starting point. + +- For every enum/mode and supported parameter, test defaults, minimum/maximum, + near-boundary values and representative intermediate values; pair dependent + controls and exercise relevant adversarial combinations. State clearly that + this is bounded coverage rather than every point in a continuous parameter + space. Use silence, impulses, steps/bursts, multitone/sweep/noise, transients and + identified vocal/guitar/drum/music material, with mono/stereo and varied input + headroom. +- Check effect-specific laws: EQ/GEQ frequency/gain/Q/phase and dynamics targets; + compressor ratio/knee/timing/sidechain/mix and model-specific colour; gate + threshold/range/hysteresis/hold/expansion; limiter final-output peaks, release, + stereo links and latency; nonlinear bias/headroom/alias/IMD; Delay timing and + feedback/diffusion; Chorus rate/depth/voices and sync; Reverb onset, decay, + spectral damping, hold/input policy and engine transitions; convolution + geometry/order/trim/routing; instrument pitch/envelopes/controllers/voice + ownership and releases; realtime pitch detector/correction/latency. +- Exercise parameter ramps, randomized callback partitions, mode switches, + complete-state recalls and concurrent editor activity. Check finite output, + denormals, stale tails, discontinuities, DC, channel isolation, bounded memory, + declared latency/tail and live/offline consistency. Diagnostic spectral or + timbral measures need calibrated expectations before becoming pass/fail gates. +- Measure representative and stress settings at 44.1/48/96/192 kHz and supported + small/normal buffers, including 8/16/32/64/128/256/512 samples where the device + actually supports them. Include multiple effect instances, dual armed-track + monitoring, oversampling, sustained polyphony, IR/FIR preparation and the + expensive old/new-engine overlap during transitions. Debug speed is diagnostic; + Release timing on one machine is not a portable realtime guarantee. +- Prioritize known retained risks: Shimmer and realtime Pitch Correct deadline + overruns, Original/Spread transition costs, unqualified indefinite Infinite + Hold accumulation, and high-quality oversampling cost. Do not reduce quality, change + Hold input semantics or drop voice/tail work merely to improve a benchmark. + Consider settings caches, bounded incremental work and proven inaudible-engine + sleep only after profiling and state/transition parity checks. +- The pre-implementation October 22 audit identified Nonlinear Infinite guard + activation under continuing input and Corrected Pitch deadline misses at + 192 kHz with four instances at 32/64/128-sample buffers. Those are dated + baselines, not measurements of every subsequent revision. Infinite still + accepts new input; attenuating Output does not prove internal headroom, and + Freeze must not replace Infinite as a transparent repair. The implemented + buildup handling subsequently passes all 16 bounded headroom fixtures on the + final Release build. Indefinite accumulation and listening acceptance remain + unasserted; current evidence and limits are maintained in the testing guide. +- The working-tree stress fixtures now select the explicit Shimmer enum for + Room-to-Shimmer transitions, distribute 32 note-ons across two MIDI channels, + and report observed rendering voices separately from requests. The headless + render/export entry chooses offline device startup before AudioEngine + initialization. These repairs make the intended cases observable; skipped + host/export work remains `not_asserted`, and an offline pass does not qualify + a physical device. +- Source-preserving work now includes unchanged-setting caching in Clear, + prepared note frequencies and invalidated bend/pan/invariant-work caches in + Synth, and bounded incremental pitch-detector/FFT scheduling. Pitch retains + its warm processing history during bypass; bypass sleep remains a separate + proposal rather than an implemented optimization. Cache invalidation, + controller expression, sample-rate behavior, transition history and callback + allocation remain regression obligations. These implementation descriptions + make no benchmark or listening claim; ordinary factory profiles and one + modest session cannot clear high-rate or transition deadline cases. +- Keep exact rendered artifacts and source/model/configuration hashes. Listening + compares loudness-controlled references where appropriate, includes a whole + musical chain, and records user acceptance for that exact revision. Videos or + manuals provide context; reviewing their text does not constitute listening. + +Current measured failures and new qualification results belong in the existing +[testing guide](testing.md#retained-audio-qualification-and-limits), with raw +artifacts under ignored `output/`. The roadmap links these gates; do not create +parallel implementation-plan documents or treat a planned test as a completed +result. ## Source ownership @@ -24,13 +193,29 @@ The approved prototypes and repeated review screenshots were development aids. T | Native schema, configuration preparation, host routing and telemetry | `Source/AudioEngine.*`, its focused `.inc` implementations, `MainComponent.cpp`, `TrackProcessor.*` | | Editor selection and bounds | `frontend/src/components/builtin/editorRegistry.ts`, `BuiltInPluginPanel.tsx`, `Source/Main.cpp` | | Shared gestures, optimistic writes and retained instance history | `SuiteParameter.tsx`, `BuiltInPluginPanel.tsx`, `builtInPluginParamHistory.ts`, `suiteParameterGesture.ts` | +| Find and responsive instrument navigation | `PluginFeatureFinder.tsx`, `pluginFeatureNavigation.ts`, `InstrumentEditorParts.tsx`, `instrumentControlLocation.ts` | | Presets, Compare and host bypass | `EQToolbar.tsx`, native full-state operations, `AudioEngineBuiltInPresetFiles.inc`, `builtInHostBypassHistory.ts` | | Shared control styling | `PluginEditorControls.ts` Tailwind utilities; processor-owned CSS for artwork, pseudo-elements and scene geometry | | Graphical pitch entry and ownership | `PitchFXEditorEntry.tsx`, `pitchEditorFXEntry.ts`, `pitchEditorStore.ts`, `PitchEditorLowerZone.tsx`, `PitchEditorWindowApp.tsx` | | Native correctness fixtures | `Source/FreePluginRegression.cpp`, its family regression headers, host regression `.inc` files and `tests/fixtures/free-plugins/` | +| Real-editor browser fixtures | `frontend/e2e/support/freeSuiteHarness.ts`, `free-suite-design03.spec.ts`, `free-suite-effects.spec.ts`, `free-suite-precision.spec.ts` and `tools/run-free-suite-ui-regression.ps1` | Small DSP headers and regression files are dependencies, not generated output. Keep compatibility fixtures immutable when fixing a regression. Avoid merging unrelated modules merely to reduce Git's file count. +The Windows PowerShell browser runner exports the selected native +`editor-schemas` group, then runs the three `free-suite-*` specs and +`release-review-ui` against the same shared fixture. Build the current Debug app +and install frontend/Playwright dependencies +before invoking it; the runner does not rebuild or prove that an existing binary +matches the working tree. Its schema report belongs in ignored +`output/review/free-suite-ui/`. `OPENSTUDIO_FREE_SUITE_SCHEMAS` can select an +explicit report for direct Playwright runs. An explicitly requested missing or +invalid report fails qualification. A direct run without an explicit path may +skip when its default fixture is absent; that is not a qualification pass. +The browser bridge +records parameter, gesture and audition calls without processing audio; it does +not qualify Windows WebView2, Linux, macOS or a physical audio device. + ## Implemented capability boundaries These are implemented paths, not claims of commercial equivalence. The manual documents their controls in detail. @@ -44,7 +229,7 @@ These are implemented paths, not claims of commercial equivalence. The manual do | Limiter | Eight original responses, audio oversampling, final-output guard, latency alignment, reconstructed-peak and loudness/history displays | Finite final-output tests are not an arbitrary-input ceiling proof or full BS.1770/EBU certification. Export dither belongs after sample-rate/channel conversion. | | Preamp / Saturator | Stepped preamp tone/HPF and separate input/output drive/trim; retained Legacy and original stateful saturation voices, compensation/mix and meters | Compensation is an estimate. Circuit matching, broad alias qualification and perceptual acceptance remain open. | | Gain Phase | Manual fractional timing/polarity, all-pass and saved 48-point/channel spectral FIR; 2-8-instance capture, proposal audition and whole-group Undo | Fixed-duration sparse captures are explicitly sparse. To-project-end streams the selected span through a bounded queue for up to 30 minutes. Route/source/latency changes or missing coverage invalidate Apply. | -| Reverb | 41 types across studio, vintage, plate/modal, spring, shimmer, nonlinear, spatial, ambient and convolution families; per-type memories, Send/mix lock, supported Hold and wet-tail spillover | Controls follow the actual engine. Legacy crossfades, finite-only modes and deferred changes under Hold remain explicit. Nominal decay/geometry is not a measured commercial room or circuit. | +| Reverb | 41 types across studio, vintage, plate/modal, spring, shimmer, nonlinear, spatial, ambient and convolution families; per-type memories, 41 authored factory profiles plus collection, Output/return trims, opt-in Spread Shimmer scheduling and Vowel broadband body, Send/mix lock, supported Hold and wet-tail spillover | Controls follow the actual engine. Spread adds one pitch hop and prepared memory; old state retains Original. Legacy crossfades, finite-only modes and deferred changes under Hold remain explicit. Nominal decay/geometry is not a measured commercial room or circuit; listening acceptance is unqualified. | | Convolution | Portable mono/stereo/four-path IRs; cancellable preparation, shaping, synthetic extension/brightness, source blend, wet EQ/modulation, declared geometry, isolated audition and diagnostic decay analysis | Four outputs split diagonal paths to Main 1/2 and cross paths to auxiliary 3/4. This is not arbitrary surround or measured venue reconstruction. Applied IR remains usable when preparation fails/cancels. | | Delay | Digital/Tape/Analog/Multi/Dual, stereo timing/sync, tone/colour, motion, ducking and post-repeat diffusion | Native effective timing includes tempo source/capacity. Existing diffusion adds span after the repeat. Pitch/reverse/frequency-shift families and compensated diffusion remain explicit scope decisions. | | Chorus | Chorus/Flanger/Phaser with contextual voices/stages, independent sync divisions, waveform, feedback and tone | Ensemble discloses effective minimum voices and cutoff; inactive Phaser spread stays saved. No TAL/Juno emulation claim. | @@ -70,7 +255,7 @@ These are implemented paths, not claims of commercial equivalence. The manual do Compressor and Preamp retain Legacy as both the new-instance and missing-field default. Opt-in Original uses model-specific nonlinear input/output laws, low-frequency state, drive memory, DC rejection and digital headroom scaling. It is original processing, not a fitted model of a commercial circuit. -Compressor places input colour before reduction and output colour after it; both VCA response engines apply exactly one stage pair. FET Ratio Off preserves colour. Clean/Legacy compressor models remain linear. Preamp places input colour before Tone EQ, output drive after Tone EQ, and linear trim last. +Compressor places input colour before reduction and output colour after it; both VCA response engines apply exactly one stage pair. FET Ratio Off preserves colour. Clean/Legacy do not add Original nonlinear colour stages. Preamp places input colour before Tone EQ, output drive after Tone EQ, and linear trim last. Original uses prepared 16x FIR processing. Its filter delay is 78 samples; Compressor additionally reserves 20 ms (1038 samples total at 48 kHz). Legacy Preamp retains its 4x IIR path. Character changes are prepared configuration edits, while supported headroom/output-drive controls are automatable. Dry, processor bypass and host bypass must all follow the prepared latency. A conservative one-second stage tail excludes intentional Punch noise. @@ -87,15 +272,151 @@ Manufacturer manuals describe observable controls and workflows; they do not dis | [UA 1176](https://help.uaudio.com/hc/en-us/articles/4419447352980-UA-1176-Classic-Limiter-Collection-Manual), [LA-2A](https://help.uaudio.com/hc/en-us/articles/4419496124180-Teletronix-LA-2A-Leveler-Collection-Manual), [LA-3A](https://media.uaudio.com/assetlibrary/l/a/la-3a-manual.pdf) | Input-driven versus reduction/gain workflows, independent amplifier colour and history-dependent optical recovery. Hardware meter scales are not interchangeable with dBFS. | | [Focusrite Red 3](https://userguides.focusrite.com/hc/en-gb/articles/21547985697682-Using-the-Red-Plugin-Suite-s-Red-3-Compressor), [dbx 160](https://assets.wavescdn.com/pdf/plugins/dbx-160.pdf) | Distinct VCA timing/knee/routing and monitoring requirements; retain independent original response laws. | | [Neve 1073](https://help.uaudio.com/hc/en-us/articles/4419497223188-Neve-1073-Preamp-EQ-Manual), [Decapitator](https://www.soundtoys.com/wp-content/uploads/Decapitator-Manual.pdf) | Gain staging, stepped tone/HPF and separate drive/output decisions. Digital headroom is not hardware impedance or dBu calibration. | -| [Pro-G](https://www.fabfilter.com/help/pro-g), [Pro-L oversampling](https://www.fabfilter.com/help/pro-l/using/oversampling), [L2](https://assets.wavescdn.com/pdf/plugins/l2-ultramaximizer.pdf) | Detector/envelope distinctions, reconstruction after rate conversion and separation of limiting from final export quantization. | +| [Pro-C 2 manual](https://www.fabfilter.com/downloads/pdf/help/ffproc2-manual.pdf), [basic compressor controls](https://www.fabfilter.com/learn/compression/basic-compressor-controls) | Ratio 1:1 removes reduction; Attack/Release govern response, and Makeup/Mix remain separate. Use these observable laws for the Clean engine, not a copied commercial response. The versioned PDF preserves the Pro-C 2 reference; the current unversioned online help now describes Pro-C 3. | +| [Pro-G manual](https://www.fabfilter.com/help/ffprog-manual.pdf), [BOSS NS-2 manual](https://static.roland.com/assets/media/pdf/NS-2_e01_W.pdf) | Threshold, attenuation range, hysteresis, opening, Hold and closing are distinct decisions. Lookahead is optional, so a no-lookahead gate need not preserve a single-sample pulse. The NS-2 separates instrument detection from noisy effect returns and places Delay/Reverb after suppression to retain their tails; this is routing guidance, not a matching detector curve. | +| [Pro-L true-peak limiting](https://www.fabfilter.com/help/pro-l/using/truepeaklimiting), [oversampling](https://www.fabfilter.com/help/pro-l/using/oversampling), [L2](https://assets.wavescdn.com/pdf/plugins/l2-ultramaximizer.pdf) | Distinguish an input peak detector or final sample clamp from reconstructed peaks after gain modulation and downsampling. Qualify final output independently and keep limiting separate from final export quantization. Finite fixtures do not establish full metering-standard conformance. | | [Avid Audio Plug-Ins 2024.3, D-Verb](https://resources.avid.com/SupportFiles/PT/Audio_Plug-Ins_Guide_2024.3.pdf), [VintageVerb modes](https://valhalladsp.com/2023/02/10/valhallavintageverb-the-modes/), [PLATE-140 1.0.0](https://downloads.arturia.net/products/rev-plate140/manual/ReverbPlate_Manual_1_0_0_EN.pdf) | Mode-specific space controls, decay hierarchy and separation of plate input colour from wet processing. Unpublished size/decay/density laws remain uncalibrated. | -| [BigSky plug-in Rev A](https://www.strymon.net/manuals/BigSky_Plugin_UserManual_RevA.pdf), [RAUM Freeze](https://docs.native-instruments.com/ni-tech-manuals/raum-manual/en/global-controls-and-freeze) | Stable common controls with contextual engine detail; continuing-input versus input-rejecting Hold and deferred topology changes. Use each OpenStudio engine's actual policy. | +| [BigSky plug-in Rev A](https://www.strymon.net/manuals/BigSky_Plugin_UserManual_RevA.pdf), [BigSky MX Rev B](https://www.strymon.net/manuals/BigSky_MX_UserManual_RevB.pdf), [RAUM Freeze](https://docs.native-instruments.com/ni-tech-manuals/raum-manual/en/global-controls-and-freeze) | Stable common controls with contextual engine detail; continuing-input versus input-rejecting Hold and deferred topology changes. MX page 21 explicitly keeps new notes outside Freeze but admits them in Infinite. Use each OpenStudio engine's actual policy. | | [Altiverb 8 manual](https://www.audioease.com/altiverb/files/Altiverb-8-manual.pdf), [REW decay analysis](https://www.roomeqwizard.com/help/help/html/graph_rt60.html) | IR identity, preparation and channel routing; separate declared geometry and diagnostic decay from measured venue reconstruction/certified acoustics. | | [ValhallaDelay diffusion](https://valhalladsp.com/2019/06/13/valhalladelay-the-diffusion-section/), [TAL-Chorus-LX](https://tal-software.com/products/tal-chorus-lx) | Strong timing hierarchy and compact modulation controls. Existing OpenStudio diffusion timing and mode names retain their own contracts. Valhalla live pages were access-blocked during the visual review; the inspected 2013/2019 screenshots were historical, not current-version evidence. | +| [TimeLine Rev F](https://www.strymon.net/manuals/TimeLine_UserManual_REVF.pdf), [Mobius Rev F](https://www.strymon.net/manuals/Mobius_UserManual_Mobile_REVF.pdf) | TimeLine pages 2/22 distinguish delay time, feedback, wet/dry mix and retained preset tails. Mobius pages 2/10 distinguish LFO speed/depth and Chorus wet/dry mix; its variable-clock, multi-LFO, vibrato and digital modes are different algorithms. These establish control responsibilities, not universal chorus/delay curves or matching knob positions. | | [Auto-Align 2.2.1](https://assets.soundradix.com/downloads/Auto-Align_2.2.1_User_Manual.pdf) | Capture/review/apply, linked policies and confidence disclosure; correlation thresholds are heuristics, not acoustic correctness probabilities. | | [Surge XT](https://surge-synthesizer.github.io/manual-xt/), [Ableton instruments](https://www.ableton.com/en/live-manual/12/live-instrument-reference/), [Pianoteq](https://www.modartt.com/pianoteq_features), [Strum GS-2](https://www.applied-acoustics.com/strum-gs-2/manual/), [Ample manuals](https://www.amplesound.net/en/manual.asp), [Drum Racks](https://www.ableton.com/en/live-manual/12/instrument-drum-and-effect-racks/) | Signal-flow hierarchy, articulation/string/performance ownership and incoming-note versus mapped-piece identity. They do not change the no-pack synthesized-instrument scope. | | [Auto-Tune Pro 11](https://www.antarestech.com/documentation/auto-tune-pro-11), [MAutoPitch](https://www.meldaproduction.com/MAutoPitch) | Realtime target and sustained-note correction are distinct from graphical clip editing. Preserve Legacy Humanize while qualifying the note-age-aware option. | +Manufacturer videos supplement these written contracts: [FabFilter's Pro-C 2 +introduction](https://www.youtube.com/watch?v=2zjQNeM2RxU) and [Strymon's BigSky +Infinite Sustain/Freeze demonstration](https://www.youtube.com/watch?v=Qv9QWoHCFhQ). +The October 22 review inspected available titles/descriptions and written manuals; +it did not listen to these demonstrations or compare their audio with OpenStudio. +Infinite continues adding new input to the held reverb; Freeze excludes new input +from the held wet path. Muting new input is therefore not a sound-preserving fix +for Infinite overload. Retain OpenStudio's own algorithms and evaluate any new +overload policy separately. + +The same review found contextual-control gaps addressed in the working-tree +editors: Gate disables Hold/Hysteresis in Expander mode, and Limiter Classic explains +its inactive Slow Attack, Auto Release, Transient Link and Release Link controls. +Edge has its own applicability explanation. These settings remain saved when +their controls are absent or disabled. Generic Compressor Stereo +Link blends average and maximum detection; zero does not select dual mono. +These are presentation/semantics findings, not authorization to replace the DSP +with the referenced products' algorithms. + +## Corrected signal paths + +EQ, Preamp, Saturator, Chorus, realtime Pitch Correct, Basic Synth, Piano, +Clean Guitar and Drums append **Plugin options > Signal path > Legacy / +Corrected**. New instances and factory reset use Corrected. Older complete +states, named numeric snapshots and EQ MIDI configurations missing the field +use Legacy. This is a stopped configuration change that resets processing +history; full-state Undo, Compare and portable recall retain it. Descriptor +indices and persistence versions are unchanged. Instrument voice engines and +Preamp/Compressor audio character are independent choices. Embedded NAM paths +retain their existing defaults. + +The inline Pitch Correct panel also stores Signal path in its custom presets +and A/B snapshots, together with the deeper detection, formant and MIDI +settings exposed by the full editor. An old custom preset without Signal path +uses Legacy so its root-relative note mask keeps its meaning. Factory tonal +templates preserve the current mode and note mask. Recall applies one guarded +native state transaction; a failed write or preset file save is reported. + +| Corrected path | Maintenance contract | +| --- | --- | +| EQ / Saturator | Static EQ retains finite floating-point headroom; Foldback is symmetric. Saturator type fades end at their exact sample endpoint across callback partitions. | +| Pitch Correct | Absolute C–B masks affect Custom only. Anti-aliased rate-normalized analysis covers the configured low-note range; incremental YIN and FFT work preserve the pitch algorithm's settings. | +| Piano / Guitar / Drums / Chorus | Fractional components and Ensemble secondary LFOs advance continuously. Corrected drum metal partials fade near Nyquist. | +| Synth / Guitar | Prepared one-pole coefficients preserve their 48 kHz reference time constants. | +| Preamp | Internal bypass returns latency-aligned dry audio; oversized callbacks are processed in bounded chunks. | + +Corrected pitch analysis uses a 22.05–32 kHz stream and 2048-sample window +(64–93 ms), plus up to one analysis hop of distributed decision work (about +23 ms). Reported audio alignment latency is unchanged. The bundled Stretch +fine-FFT opt-in is documented beside the [vendor patch](../thirdparty/signalsmith/OPENSTUDIO-PATCHES.md). +This detector delay and the measured small-buffer/high-rate deadline failures +remain relevant; see [qualification limits](testing.md#retained-audio-qualification-and-limits). + +Signal-preserving fixes apply independently: Delay, Chorus and Saturator prepare +all IIR orders off the callback; drum CC123 releases one-shots while CC120 remains +panic; export bounds include feedback, filter ringing, manual delays and drum +decay. Articulated Drums disables Kit and Hi-hat Tightness, which belong to its +Legacy voice engine. Conservative tail declarations can include silence. + +Standalone Gate and the uncoloured Clean compressor preserve finite wet-path +headroom instead of hard-clipping at a fixed 2.5 peak (about +7.96 dBFS). This +applies to active processing, not just bypass: Gate Range 0 and Clean Ratio 1:1, +Makeup 0 must retain the input after startup settling and reported latency. +The embedded NAM gate and Legacy/coloured compressor paths retain their prior +bounds, including outgoing coloured/noise blends. No parameters, state schemas, +gain envelopes or latency changed. + +The October 22 Debug `dynamics-audit` reproduced 32 failing high-level cases +before the correction and passed all 64 afterward: four rates, mono/stereo and +0.125/1/3/8-peak inputs. Clean's maximum unity error was zero; Gate's was below +0.00000048. All 32 normal-level summaries and the existing 64 Clean RMS transfer +rows were unchanged. Separate Gate onset/expansion, external-key and 42 non-Reverb +baseline checks, including 360 control/rate/block/channel cases, passed. These +are deterministic contracts, not an audition or clearance of the retained Reverb, +pitch/CPU and hardware qualification limits in [testing](testing.md#retained-audio-qualification-and-limits). + +## Standalone Reverb state and processing + +The 41 types use 13 DSP families. Per-type banks keep edits when browsing; +**Load voice preset** recalls one complete authored profile and **Load voiced +collection** installs all 41 banks. Both use ordinary complete-state Undo. +Eight historical voices remain available. Width, Wet, Dry, Output and Processing +are shared, so the collection does not reproduce every individual profile's +shared settings. User presets retain priority over same-name generated entries. + +| Shared choice | New instance / missing old field | Behavior | +| --- | --- | --- | +| Output | 0 dB / 0 dB | -60 to +12 dB after complete mix/ducking, including retained tails and convolution auxiliary returns; independent of Mix Lock. | +| Return level | Calibrated / Original | Fixed wet-readout trims outside feedback. Legacy Room/Hall/Chamber/Plate compatibility engines and custom IR levels remain unchanged. | +| Processing | Original / Original | Refined uses settled motion-off integer taps in eligible digital engines and a signed wet-output FIR in Studio Plate/Cloud. It deliberately changes frequency coloration. | +| Controls | Corrected / Legacy | Append-only index 1089; per-sample 20 ms Wet/Dry ramps, bounded Spatial modulation and reference-rate damping. Historical factory voices keep Legacy; Voiced profiles select Corrected. | +| Shimmer pitch scheduling | Original / Original | Spread preserves the 120 ms window/30 ms hop and adds one pitched-path hop; Voiced Shimmer/collection opt in. | +| Vowel broadband body | 0 / 0 | A ramped wet feedforward bed beneath the formants; dry and feedback are unchanged. Voiced Vowel/collection opt in. | + +All choices survive projects, presets, portable files, Compare and Undo without +a schema/DSP version bump. NAM Rack is excluded. Historical Legacy Wet/Early +routing remains: Early can be audible at Wet zero. Plate input colour and +Positioned room's spatial direct path can also change the dry signal, so compare +wet residuals against each voice's own Wet-zero render. + +`BuiltInReverbDamping.h` maps poles during configure with +`p(Fs) = p(48000)^(48000/Fs)`, preserving an exact 48 kHz branch and explicit +bypasses. Tone ramps are 50 ms. Vintage/Retro era conversion remains separate. +This preserves a filter time constant, not identical near-Nyquist spectra or a +measured broadband RT60. Voiced Shimmer sends pitched Input into the long tank +once; saved Tank/Both routes retain recirculation losses and nominal Decay. +Shaped Duration displays 25–2000 ms while retaining normalized storage; Shimmer +Amount resets to its bank's 50% default, and Bloom Length has no effect at zero +Diffusion. + +Spread uses existing incremental FFT stages and deterministic role offsets. +Live scheduling changes prime at most one silent sample per real input sample +before warmup and a 20 ms fade. Steady Spread/Input suspends the unused tank pitch +pair; Tank/Both selection refills it for about 300 ms plus 64 samples before a +50 ms route fade, keeping Input audible during warmup. Both banks stay allocated. +Transitions involving Original temporarily retain its unsplit spectral work. + +Masked compatibility engines sleep only after complete zero-weight coverage for +at least 50 ms with an unchanged routing fingerprint. Routing/publication changes +invalidate that proof; wakeup invalidates hidden history rather than replaying it. +Dedicated/draining engines keep their transitions, Legacy selectors and embedded +NAM keep their paths. Settings caches skip exactly unchanged configuration work; +muted Spring histories remain running to preserve later count changes. + +Individual Voiced profiles use fixed Output trims from 0 to -13 dB, targeting +3 dB sample-peak headroom on the shared guitar at Dry70/Wet100; the collection +uses one -14 dB trim. Output changes dry and wet together. These are reference +choices, not automatic gain control or arbitrary-source/Hold headroom guarantees. +Continued-input Infinite Hold can reach internal/output clamps; finite-output +and release passes do not establish unclipped indefinite Hold. Current evidence, +reproduction commands and unresolved listening/device limits belong in +[testing](testing.md#standalone-reverb-qualification). + ## Remaining work and explicit scope decisions These rows retain the older reference backlog without its superseded schedules and completed-task diaries. A design approval or deterministic pass does not close them. @@ -111,5 +432,3 @@ These rows retain the older reference backlog without its superseded schedules a | Pitch | Audition sustained Humanize and combined graphical/realtime routes. MIDI targets, vibrato/harmony/Flex-Tune-like behavior and lower-latency alternatives remain separate extensions; none authorize a replacement pitch canvas. | | Reverb | Independent size/decay/density/era, material/transducer, hold/transition and live/offline calibration. Certified decay analysis and measured position reconstruction remain open. Current four-output convolution is implemented; arbitrary surround is separate. | | Instruments | Physical MIDI/controller/device qualification and listening. Full primary-string/hammer/body simulations, proprietary sample libraries, full Strummer/Riffer/Tab or groove products remain outside the bounded no-pack delivery unless separately requested. Drum auxiliary outputs are implemented. | - -Local validation on 3 October recorded 2,480 frontend tests, 168 native suite checks, 233 host integration checks and the stated browser/native-window cases as `pass`. Intermediate failures were retained locally and corrected, including expansion onset behavior, VCA colour routing and bypass latency recall. These counts are a dated baseline, not a permanent certification or a substitute for running the current source's checks. diff --git a/docs/input-profiles.md b/docs/input-profiles.md index 4f1d2e9..4691560 100644 --- a/docs/input-profiles.md +++ b/docs/input-profiles.md @@ -30,6 +30,16 @@ base profiles. ## Binding vocabulary +The October 12, 2026 development checkout uses these **OpenStudio** defaults: +`R` toggles Record, with Primary+R and physical numpad `*` as aliases. `1` or +Home seeks to absolute project time zero and scrolls the timeline to its start. +`+`/`=` and numpad Add zoom the timeline in; `-` and numpad Subtract zoom out. +Primary+`+`/`-` remain aliases. Track Arm and Piano Roll Select Range use +Primary+Shift+R in their separate scopes; Pitch Editor Select uses `V`. +Other built-in profiles retain their documented bindings, and custom overrides +continue to take priority. The shortcut window and control tooltips show the +effective bindings for the active profile and platform. + The Cubase profile maps **F6** to the track envelope panel, **Alt+R** to uniform all-track Read and **Alt+W** to uniform all-track Write (**Option** on macOS). Write enables Read; switching Write off leaves Read on, including on empty tracks. Master R/W is controlled separately. These bindings describe the development checkout and remain subject to custom overrides and editor focus. - **Primary** is Control on Windows/Linux and Command on macOS. @@ -111,6 +121,29 @@ shortcut examples in the manual show the OpenStudio default profile only. ## Mouse and wheel safety +With the default Seek ruler mapping, a plain horizontal drag sets the +project/loop range; it leaves the separate editing time selection unchanged. +A vertical drag zooms time around the original pointer position: upwards zooms +out and downwards zooms in. After the movement threshold, the gesture keeps its +first chosen axis. A click seeks, and range handles resize their endpoints. +This is horizontal timeline zoom, not track height. + +Explicit profile/custom ruler actions take priority: **Select time** sets or +extends the edit selection, **Set loop** changes the project/loop range, and +**Zoom to** fits a horizontal range. These actions do not become vertical zoom; +an explicitly disabled mapping remains inert. The OpenStudio modifiers are +Primary for loop range, Shift for time selection and Alt/Option for zoom range; +inspect the active profile and custom overrides for the effective gestures. + +In the Piano Roll, Primary+note-body drag changes time while holding each +note's exact pitch (and bypasses grid snapping); Shift+drag changes pitch while +holding each note's exact time. Shift-click still toggles note selection. +Primary+clip drag copies the full selected clip group, and Primary+track-header +drag copies the selected whole tracks. Both show half-transparent previews and +commit one Undo step. When a clip group needs new compatible destination tracks, +the appended span preserves source row ordering and gaps; preview and committed +placement agree. + 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. diff --git a/docs/linux-installation-and-qualification-plan.md b/docs/linux-installation-and-qualification-plan.md index 5020a3e..5c27b86 100644 --- a/docs/linux-installation-and-qualification-plan.md +++ b/docs/linux-installation-and-qualification-plan.md @@ -1,5 +1,91 @@ # Linux installation and feature qualification +## 0.1.06 SSH candidate qualification (2026-10-23) + +The unpublished candidate passes the recorded Ubuntu 22.04/26.04 source, +packaged startup and regression checks, plus DEB upgrade/removal/reinstall in +an isolated VM. The existing physical-host installation and checkout remain +unchanged. The [testing guide](testing.md#ssh-linux-qualification-of-the-working-tree) +owns the exact source/artifact hashes, commands, results and evidence locations; +the [issue matrix](testing.md#open-github-issue-scenarios) records each reporter's +remaining reproduction requirements. Keep those results there rather than +duplicating a release checkpoint in this platform guide. Native Wayland, Arch/FUSE, +physical audio/MIDI, listening and final downloaded-package acceptance remain +outside these Xvfb/VM results. + +## Published-package recheck (2026-10-22, issue #23) + +[Issue #23](https://github.com/sdevil7th/OpenStudio/issues/23) originally reports +0.1.03 AppImage resource lookup on Arch Linux. The October 21 follow-up says a +newer build still fails, but supplies no artifact name, hash or fresh log. That +report remains unverified; moving to DEB does not establish an Arch fix. DEB is +the Ubuntu/Debian/Mint package format, and RPM is the Fedora format; neither is +an Arch-native installer. + +The current [v0.1.05 release](https://github.com/sdevil7th/OpenStudio/releases/tag/v0.1.05) +contains **both** a DEB and an AppImage, with no RPM asset. Its source tag is +`649012c77f86eb9cbfee616bd16a588576c63112`. Read-only downloads matched the +published checksums: + +| Artifact | SHA-256 | +| --- | --- | +| `OpenStudio-0.1.05-1-ubuntu-22.04-amd64.deb` | `6185a87294cf91e280da0179f74962db1c7b73725de065be82a555c1b845f9a9` | +| `OpenStudio-0.1.05-linux-x86_64.AppImage` | `2f6cfd58086eecf68f2020c35f6a1f1f5d5ea051b00cf0d47c369229bd35c733` | + +Archive inspection found the DEB executable and sibling `webui`, `effects`, +`scripts` and model assets under `/usr/lib/openstudio`, with `/usr/bin/OpenStudio` +linked to that executable. The AppImage contains the same required asset layout +under `usr/bin`, and `AppRun` links to `usr/bin/OpenStudio`. Both ELF payloads +contain the `/proc/self/exe` lookup introduced in committed +[d67637ca](https://github.com/sdevil7th/OpenStudio/commit/d67637ca776bc0d521437f511effebc57aaae8e8). +This resolves resources from the running payload rather than the outer AppImage +filename or inherited `APPDIR`. The DEB dependency metadata declares the Ubuntu +22.04 glibc floor and system GTK/WebKit dependencies. Archive presence and these +strings establish packaging/source evidence, not a fresh launch result. + +The release's [Ubuntu Linux job](https://github.com/sdevil7th/OpenStudio/actions/runs/37790026737/job/113354712043) +succeeded, including installed native-package qualification, packaged AppImage +launch and the finished-launcher lifecycle gate. Those gates require main and +detached frontend readiness, beyond `--startup-self-test`. They qualify that +Ubuntu X11/XWayland CI environment, not native Wayland, Arch, physical audio or +every supported desktop. The website currently prefers the DEB for a new Linux +download; its legacy Linux download/updater contract still points to AppImage. + +The current working-tree qualification tool also clears the three NAM-only +fixture selectors, so inherited developer environment variables cannot narrow +`--features` silently. Portable regression checks pass on this Windows host +(4 passed; 13 ELF/packaging/process-group checks skipped). No Linux distribution +is installed in WSL here; neither downloaded package was executed or installed. +Local archive evidence is under ignored +`output/issue-23-published-20261022-36534515/evidence.json`; retain the public +hashes and CI evidence above independently of that local directory. + +### Acceptance on a Linux qualification runner + +Use the exact downloaded hash, normal installed application-menu launch and a +fresh evidence directory for each mode. On each supported DEB/RPM distribution, +verify declared dependencies, offline startup, normal and safe main/detached +`boot-ready`, and removal/upgrade behavior. For AppImage, qualify both extracted +payload resource lookup and the finished launcher from an unrelated working +directory; prerequisite self-test alone is insufficient. An Arch reproduction +needs the current artifact identity and log, then real frontend readiness against +that host's GTK/WebKit dependencies. Existing Ubuntu results cannot substitute. + +These commands are developer qualification steps on a prepared Linux runner, +not customer installation requirements (replace the artifact/fixture paths): + +```bash +xvfb-run -a python3 tools/run-linux-qualification.py --app /usr/bin/OpenStudio --output output/linux-installed-normal --desktop --render --features --nam-fixtures /path/to/example_models +xvfb-run -a python3 tools/run-linux-qualification.py --app /usr/bin/OpenStudio --output output/linux-installed-safe --desktop --safe-mode +python3 tools/smoke-linux-appimage.py /path/to/OpenStudio-0.1.05-linux-x86_64.AppImage --report output/linux-finished-appimage.json +``` + +The first two commands also exercise changed `argv[0]` and stale `APPDIR` asset +lookup; the last exercises the finished AppImage with a fresh profile and window +manager. Follow with the actual target desktop; Xvfb cannot establish native +Wayland or hardware behavior. The older dated checkpoints below retain their +original scope and do not describe the current public artifact inventory. + Latest AI follow-up: [installed AI audit on Ubuntu 22.04/24.04/26.04](ai-ubuntu-qualification-2026-10-01.md). Revision 11 passes in-app stem installation/inference; Basic Pitch fails and full generation qualification remains blocked. This supersedes earlier AI status below. @@ -10,7 +96,7 @@ harness ownership leak is corrected and diagnostic failures now fail qualificati Live Audient DAW signal verification and native Windows/macOS remain open. -Status (2026-10-01, latest checkpoint): revision 6 DEB adds the Linux mixer-control +Status (2026-10-01 checkpoint): revision 6 DEB adds the Linux mixer-control correction; revision 5 remains the last four-distribution DEB/RPM qualification. Both use the Ubuntu 22.04 native build. Installed checks run in real Ubuntu 22.04, Debian 13, Fedora 44 and Mint 22.3 live VMs. Fedora/Mint graphical installation and diff --git a/docs/nam-rack.md b/docs/nam-rack.md index e7addc6..e8b2ccc 100644 --- a/docs/nam-rack.md +++ b/docs/nam-rack.md @@ -131,7 +131,8 @@ The current audible route is: Input trim -> Gate detector key (sidechain only) -> Compressor - -> Stereo Poly Octaver + -> Octaver Fuzz + -> optional Fuzz (independent switch) -> EQ Boost / pre-EQ -> Precision Drive -> Distortion @@ -294,6 +295,17 @@ The main implementation lives in: combination; neither stage performs nested resampling. - No timing result from one machine is marketed as a universal CPU claim. +The current resampler indexes each 48-tap window as at most two contiguous +spans of its 128-sample history ring. The kernel, phase scheduler, zero padding +and ordered double-precision accumulation remain unchanged; this removes +repeated indexing work rather than selecting a different resampling algorithm. +Delay also returns before resolving its macro controls when disabled with no +remaining tail. Off-state edits are applied on engagement, while existing tails +still drain through the normal processing path. Exact reference/lifecycle +contracts gate these optimizations separately from wall-clock timing; see +[testing](testing.md#octaver-fuzz-checks). They do not establish live-interface +stability or an audible improvement. + ### Input routing and stereo NAM contract The DAW track route is the sole input-topology authority. A one-channel route @@ -351,6 +363,15 @@ 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. +**Output peaks reduced recently** means the rack's final peak protection +softened loud peaks. Lower **Output** if you want less of that compression. +The amber message follows newly observed activity and becomes neutral history +after three seconds without another event. Opening a rack with an existing +counter shows **Earlier output peaks were reduced**, not a current warning. +This differs from **Overload** (missed processing work) and the separate +emergency safety mute. The message wraps and is keyboard accessible instead +of hiding its explanation behind an ellipsis. + 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. @@ -565,7 +586,7 @@ 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 +Gate topology is unchanged. The octave section of `Octaver Fuzz` uses 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 @@ -582,6 +603,402 @@ 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. +### Octaver Fuzz (October 12, 2026 development checkout) + +The 156-pixel olive pedal replaces the former 120-pixel enclosure. Its upper +row is Down/Up/Direct and its lower row is Fuzz/Tone/Level. The header provides +a Vintage/Sustain/Gated voice selector; separate Octave and Fuzz footswitches +and LEDs indicate the two active sections. The PRE row is 784 pixels wide, +centred on the existing rack artboard. The October 23 layout integrates a +dedicated Clean knob beside Voice, with a visible percentage. Enter/right-click +opens exact entry; Escape cancels, double-click resets, and keyboard adjustment +and Undo share the normal parameter path. Device Controls retains the same +parameter. A registered SVG silkscreen overlays the unchanged olive body; +stationary artwork and interactive controls have separate, measured bounds. + +The October 15 follow-up shortens the faceplate and compact product name to +**Octaver Fuzz**. Stereo/polyphonic operation is unchanged. + +Fuzz runs after the stereo octave/direct mix and before PRE EQ. With Octave +off it processes the unshifted signal. Vintage uses two asymmetric clipping +cells with envelope-dependent bias and sag; Sustain uses two symmetric cells +and a complementary scooped tone branch. Its bright branch retains a 15% +low-frequency floor to avoid the measured bass/clean phase opposition of the +original pure high-pass. Gated uses biased asymmetric cells and a smoothed +channel-local gate. These are original digital voices, not +circuit-qualified replicas. Guitar/Bass changes internal filter centres without +changing the saved controls. + +The six stable additive parameters are `fuzzEnabled` (default off), `fuzzDrive` +and `fuzzTone` (0-1, default 0.5), `fuzzLevelDb` (-18 to +12 dB, default 0), +`fuzzVoice` (0 Vintage / 1 Sustain / 2 Gated), and `fuzzCleanMix` (0-1, default +0). Clean blend refers to the signal immediately before fuzz, including active +octave voices. Control changes and voice switching use 25 ms ramps. The +nonlinear cells use first-order antiderivative antialiasing with DC removal; +there is no additional oversampling island or reported integer plugin latency. +ADAA has its inherent fractional-sample filtering delay. Processing uses fixed +channel state without allocation, locks or file access in the callback. + +Older states and complete preset/Compare snapshots without fuzz fields restore +fuzz off. Native/portable tone state, project state, A/B Compare and parameter +Undo retain the fields. Existing persistence and DSP version markers remain +unchanged because the controls are additive and have explicit migration defaults. +Deterministic regressions cover bypass, silence, finite output/recovery, stereo +isolation, reset, callback partitions and recall. Voice metrics are +`diagnostic_only`; perceived tone and reference-pedal similarity are +`not_asserted` until user audition. + +The order protects octave tracking from fuzz: octave analysis receives the +pre-fuzz signal. Direct/Down/Up retain their original audio mix. A separate +control-derived headroom gain scales only the nonlinear input and sag: +`max(1, Direct) / max(max(1, Direct), Direct + Down + Up)`. It uses each sample's +actual 20 ms ramped mixer coefficients, so compensation does not lead or lag +knob changes. Solo voices keep their gain and quieter settings are never +boosted. The clean branch and Gated detector retain the incoming mix level. +This bounds extra excitation from mix controls; it does not separate chord +notes or remove the sum/difference tones of shared clipping. + +Sustain has a fixed control-derived center-Tone makeup curve, bounded to 0-5 dB: +`(1 - abs(2*Tone - 1)) * (3 + 2*min(1, 2*Drive))`. Its gain ramps over 25 ms. +All fuzz wet voices then receive a shared 0.86 trim (about -1.31 dB), before +Clean blend and the user's Level. There is no audio-driven AGC, output +normalization or hidden limiter. The correction reduces default voice jumps; +bright/dark Tone settings retain source-dependent level differences rather +than boosting narrow transients to match full-band RMS. Level can still drive +the following NAM/drive and final rack guard harder. + +Partial Clean blend mixes the immediate incoming octave sum with a filtered, +ADAA-processed signal. Its frequency-dependent phase differences can color or +thin the blend; adding a fixed dry delay alone would not match all the filters. +At 100% Clean blend and Level 0 dB, the complete incoming octave mixture is +restored. The lowest bass octave-down fundamentals also encounter the octave +stage's 18 Hz high-pass plus the fuzz's Bass 12 Hz input high-pass and 12 Hz DC +blocker. Gated intentionally attenuates quiet input. Guitar retains the original +soft gate and 1/80 ms detector, with 3/12 ms gain attack/release. Bass has a +separate 1/120 ms detector, opening at the existing Drive-dependent threshold, +closing below half that threshold after 65 ms continuously below it, and +3/30 ms gain attack/release. The 6 dB hysteresis and hold preserve decaying low +notes through envelope troughs; sub-threshold re-entry can still remain gated. +Sag keeps its independent original detector timing. Stereo channel state remains +independent. These are audition cases, not findings that a particular output +sounds wrong. Aliasing is reduced by ADAA/filtering but its audibility is not +established. + +These October 15 development refinements change active fuzz voicing while +retaining all control IDs, ranges and saved values. Earlier active development +tones can sound different. Older projects without fuzz still restore it off; +exact bypass, full-clean parity, native/portable/Compare recall and callback +partition contracts are retained. There is no persistence or DSP version bump. + +The product research establishes a common pedal category, not sales rankings: +[MXR Poly Blue Octave](https://www.jimdunlop.com/mxr-poly-blue-octave/) combines +polyphonic octaves and independent fuzz; +[EHX Octavix](https://www.ehx.com/products/octavix/) and +[MXR Blue Box](https://www.jimdunlop.com/mxr-blue-box-fuzz/) illustrate octave +fuzz, while [Orange Fur Coat](https://orangeamps.com/wp-content/uploads/2017/08/FUR-COAT-MANUAL-V1.pdf) +provides an independently switched octave. Classic voice references include +[Fuzz Face](https://www.jimdunlop.com/germanium-fuzz-face-mini-distortion/) and +[Big Muff](https://www.ehx.com/wp-content/uploads/2021/01/big-muff-pi-manual.pdf). +The implementation uses the published +[antiderivative antialiasing method](https://www.mdpi.com/2076-3417/10/1/20) +with an original bounded piecewise-cubic transfer and its analytic integral. + +### Full-rig fuzz qualification (October 18, 2026) + +The October 23 Swollen Pickle review distinguishes amplified input noise from +self-generated noise. The [manufacturer's MkIIS manual](https://www.jimdunlop.com/content/manuals/WHE401S.pdf) +describes separate intensity, filtering, mid-scoop and compression decisions. +OpenStudio's symmetric, scooped Sustain voice is the closest architectural +starting point; Vintage and Gated retain their other characters. This is not +a circuit replica. The manufacturer's [instructional demonstration](https://www.youtube.com/watch?v=patkTztLSAs) +was located, but its playback could not be inspected here; no listening or +spectral-match claim is inferred from its metadata. The manual's analog noise +specification is not comparable to our unweighted digital RMS fixture. + +Vintage/Sustain excitation now follows `1.5 + 73.5 * Fuzz^4`, replacing +`5 + 70 * Fuzz^2`. This gives useful lower gain without a hidden gate or output +normalization and retains the 75x full-drive endpoint. Gated keeps its original +mapping. Moderate settings amplify less idle noise and also sustain quiet +decays less strongly; saved active development tones therefore change. The +input-keyed global Gate still needs an explicit threshold above real idle +noise, with Release chosen for the instrument. Its gain acts after the native +pedals and NAM models, before cabinet/time effects. A higher-gain stack can +remain noisy with its gate open. Neither this mapping nor a quiet synthetic +fixture qualifies the sound of a complete rig. See the current +[qualification evidence](testing.md#swollen-pickle-reference-fuzz-review-october-23-2026) +for the frozen-binary comparison and its limits. + +The stack review also corrected two numerical faults downstream of Fuzz: +Distortion's antiderivative clipping cells used unstable float subtractions, +and the shared Saturator low-cut lost precision at high oversampled rates. +Stable interval evaluation and double low-cut coefficients/state retain the +same intended transfer, cutoff and filter order. They add no oversampling +stage or latency. Saved controls and state versions are unchanged; standalone +Saturator also receives the low-cut correction, including its archived signal +path, so old and corrected renders are not promised byte-identical. Native +partition and numerical-oracle checks establish consistency; listening still +qualifies the sound. + +The approved development work evaluates Fuzz together with the existing Drive, +Distortion, NAM amp and cabinet, rather than treating pedal-only output as a +finished guitar tone. Capture the unmodified voicing first, qualify interface +calibration and compatibility, and make one audible refinement at a time after +reviewing the exact exports. Preserve the six existing fuzz controls and +old-state off defaults; do not add a persistence/DSP version change without +migration and project/preset/Compare round-trip coverage. + +Fuzz now follows the embedded Drive's fixed +12 dBu operating convention: +the interface reference determines a smoothed input gain and reciprocal wet +recovery, while the raw clean branch remains unchanged. The default +12 dBu +reference has unity calibration gain. A calibration-only capture at that +default must not be described as an independent old-build full-rig baseline. +Equivalent physical inputs at different digital interface references are a +deterministic contract; preferred distortion and gate behavior remain listening +questions. [Testing](testing.md#octaver-fuzz-checks) describes the matrix, +pairing checks and separate two-Rack timing evidence. + +Rabea Massaad's own explanations support testing several useful interactions, +not one universal pedal setting: + +- In the [Bare Knuckle interview, May 2, 2018](https://www.bareknucklepickups.co.uk/news/article/talking-boot-camp-with-rabea-afro), + he describes blending several fuzzes and octave with amp gain for different + intensities in a stereo Victory Kraken rig. +- In the [Guitar World interview, August 23, 2022](https://www.guitarworld.com/features/rabea-massaad-neural-dsp-archetype-plugin), + he discusses fuzz into a high-gain amp and an Octavia texture, and how amp + Tight changes the feel. Conversational descriptions of adding an effect do + not establish its exact signal-routing order or a published circuit model. +- In the [MusicRadar interview, September 23, 2022](https://www.musicradar.com/news/rabea-massaad-guitar-interview-neural-dsp), + he distinguishes vintage Bender-inspired fuzz from modern ThunderClaw-inspired + distortion, and discusses bass controls on fuzz and overdrive. This is his + account of the original 2022 plugin, not independent verification of later + plugin versions or OpenStudio's DSP. +- [That Pedal Show's June 2, 2023 episode and routing notes](https://www.thatpedalshow.com/TPS-videodatabase/a-hang-with-rabea-%5Bstrats,-pedalboard-update,-music-man,-neural,-real-amps,-stormzy-&-more!%5D) + place drives/compression before the Kraken preamp, with wet effects downstream + of its FX send and a separate dry cabinet. The notes identify fuzz/octave + discussion at 18:50 and overdrives at 22:20; they do not supply verified knob + values for our test matrix. + +The implementation inference is to audition octave/fuzz into further gain, +including deliberately dense combinations, while preserving the clean octave +tracking path. These sources do not prescribe exact ChaosBed/Silverfox settings, +prove a circuit replica, or establish an audible pass for the current Rack. + +The first bounded voicing candidate adds 12 Hz AC coupling between the two +Guitar/Vintage clipping cells, so the first cell's signal-dependent DC does not +unintentionally bias the second. Bass, Sustain and Gated keep their prior paths; +the coupling filter stays warm and Guitar/Bass changes blend its contribution +over 25 ms. Gains, clipping knees and post-tone controls stay unchanged. Capture the +calibrated +12 dBu full-chain baseline before applying this candidate, and use +the same fixtures/settings afterward. The intended circuit-state correction +and finite/partition checks do not establish an audible improvement; exact-file +audition remains pending. No new parameter or persistence/UI schema is planned. + +The development implementation also caches unchanged fuzz controls so normal +callbacks do not repeatedly redesign filter coefficients. Preparing at any +sample rate invalidates that cache. Fully settled, already-bypassed fuzz returns +immediately; moving ramps and active retirement still advance in sample time. +These reduce avoidable work without changing the requested controls. The +same-controls reprepare, callback partition, bypass-transition and reset +contracts protect the state semantics. They are not proof that two complete +Racks meet an 8-frame hardware deadline; see the dated +[two-instance evidence](testing.md#octaver-fuzz-checks). + +### CPU and pedal interaction contract + +The working tree implements exact sample-boundary Octaver retirement, +unchanged-control Drive/Distortion coefficient caches, broader offline scenario +coverage and clearer Fuzz/preset/device-setting workflows. Tone approval and +physical two-track capacity remain separate from deterministic test passes. +[Testing](testing.md#nam-audit-october-22-2026) retains the initial audit and +their limits. The earlier standalone free-FX qualification does not certify NAM. + +The reported live reproduction is two armed tracks with separate guitar and +bass inputs: **Bestest clean! with Distortion engaged**, plus a bass preset, +using the Audient ASIO driver, an 8-sample buffer and 4x Drive oversampling in +`python build.py dev --run`. The current saved Bestest already enables Precision +Drive, Amp/Cab, EQ, Room, Doubler and Reverb; its Octave is off and absent legacy +Fuzz fields restore off. Distortion therefore adds a second native drive stage. +This is not evidence that active Fuzz caused the reported crackling. Saved +presets also do not prove that no controls were changed during the live test. +The user could not identify which of two recovered bass presets was loaded; +both are included in qualification. Saved resource paths and hashes identify +the tested captures and IRs. Synthetic diagnostic inputs do not recreate the +original guitar/bass performance, and saved device settings do not establish +the effective rate or oversampling during the reported session. + +| Priority | Finding | Action and acceptance | +| --- | --- | --- | +| P0 | Complete two-Rack fixtures can exceed callback deadlines with Fuzz both off and on. `success` previously described signal integrity without a separate capacity gate. | Implemented explicit preset/resource scenarios, shuffled repeated timings, full measured callbacks, p99/p99.9/max and separate capacity status/CLI gate. All 600 retained offline runs pass integrity; demanding configurations still fail capacity. Both recovered bass presets are included. The follow-up silent Audient probe confirms full-engine deadline misses at 8/16 samples and passes its bounded 128-sample measurements. Original performances, visible-app workload and audible hardware stability remain unasserted. The four-track/16-sample target below remains open; the 128-sample result does not close it. Check summed output headroom separately; see Testing for limits. | +| P0 | Actual-source probing confirmed callback-dependent Octaver re-entry after full or individual wet-voice retirement, including a profile change overlapping retirement. | Implemented exact sample-boundary retirement, preservation of the still-active voice's shared state, and settled fully bypassed profile state. Permanent tests cover 3,024 retirement, 168 profile and 216 Octaver-to-Fuzz comparisons with exact output/excitation parity; 72 frozen unaffected cases remain exact. | +| P1 | Drive/Distortion redesigned unchanged coefficients each callback; Drive also calculated retired OD808 circuit coefficients on the public Precision path. | Implemented sanitized control/profile/effective-rate caches, separate gain/calibration caching and conditional historical circuit design. Prepare/reset invalidates caches. Smoothing and compatibility code remain. Forced-redesign parity and all 129 frozen default Fuzz WAVs pass; no DSP/schema version was changed. | +| P1 | Telemetry scans, normalization calculations and disabled-stage resets add overhead. | Moved unchanged normalization work behind the existing cache check. More invasive scan/reset changes are deferred: current stage timing does not isolate their benefit, and they must preserve meters, finite/peak protection and off-state edits. Safety guards remain at audio cadence. | +| P1 | Active Fuzz computes all three voices, including at Clean 100%; Octaver processes both stereo lanes. | Measured Fuzz's incremental cost is small in the retained stage fixtures; voice suspension and mono reuse are deferred because they change warm state/re-entry semantics. Keep all voices warm and independent channel state. This is an optimization candidate, not a demonstrated cause of the reported crackle. | +| P1 | The old full-chain matrix omitted Crunch, Octave-off comparisons and partial Fuzz Clean. | Implemented 531 functional full-chain cases: all 64 discrete topologies at two settings per material plus Clean, calibration, gate and octave-presence contexts. Both recovered bass presets are separately qualified for CPU with their own resources. Alternate amp families and real bass performance listening remain open; do not treat this as every continuous combination. | +| P2 | Serial clipping and Clean-blend phase differences can obscure Fuzz character. Several gates and cumulative sub-bass filtering can alter decay. | Added Clean/gate/presence/calibration coverage and aligned nonlinear residual diagnostics with independent measurement controls. Native-level listening files remain available. Exact-artifact preference and real-bass performance remain unasserted; no new hidden normalization, delay or Fuzz voicing change was made in this follow-up. | +| P2 | Clean, Level and the scope of oversampling are easy to misunderstand. | Implemented an integrated Clean knob with exact-entry, keyboard adjustment, reset and Undo, explaining the incoming octave sum, Level and downstream order. The Drive-only oversampling label remains explicit. Preset Profile review offers a named corrected copy without changing the original or active Rack. Settings reports requested versus accepted values and stays open on substitution. | + +The follow-up host audit also removes inactive instrument/IR audition clearing, +render attempts and mixing from the device callback. Active leases, release +tails and stop ramps remain eligible. Pitch scrub now advances only the actual +callback length when the prepared scratch buffer is larger. These are bounded +host corrections, not evidence that preview processing caused the reported +constant crackling. Offline wall-clock misses alone cannot distinguish DSP +work from scheduling interference; qualification must include the live ASIO +callback and retain a separate headroom check. + +#### Four-track live-monitoring acceptance target + +The requested target is **four armed, recording and software-monitored tracks +with full NAM Racks at 16 samples**, with **8 samples as the stretch target**. +Retain 4x native-drive oversampling and the requested tone; qualify explicit +preset/model/IR combinations on the user's machine at 44.1 and 48 kHz. +The earlier 128-sample result is a temporary measured operating point, not +acceptance of this requirement or evidence that a lower buffer is impossible. +The expanded silent live probe supports recording and larger track counts, +but still excludes visible editors and physical instrument signals. + +At 44.1 kHz, the complete callback has 362.8 microseconds at 16 samples and +181.4 microseconds at 8. The former serial track loop could not use additional +cores to shorten independent Rack processing. In the initial four-track Debug +live baseline, mean engine time was 441.7 microseconds, already longer than +the 16-sample period. Parallel execution reduced the mean to about 207 +microseconds in the paired preliminary run; this average improvement does not +pass the deadline requirement. Retained results and their exact scope belong +in [Testing](testing.md#multicore-nam-live-qualification-october-23-2026). + +**Implementation status: implemented in the development checkout; capacity +qualification remains open.** `RealtimeTrackExecutor` uses persistent helpers, +with an automatic count of physical CPU cores minus one (bounded to 0-127). +The device callback also claims available jobs. There is no four-track cap or +fixed thread per track; prepared jobs can outnumber helpers. On the inspected +12-core machine, automatic mode creates 11 helpers. This is a scheduling +policy, not a promise that 12 complete Racks meet a particular deadline. + +The initial path admits isolated audio tracks with a nonempty, +native-NAM-only FX graph, without ARA, incoming or outgoing sends, or +sidechains. At least two eligible tracks and an available helper are required. +MIDI/instrument tracks, hosted plugins, other native FX, and even bypassed +non-NAM processors keep their own tracks on the callback's serial path. +Unrelated ineligible tracks do not disable parallel processing for independent +NAM tracks. Connected tracks retain the existing dependency order. Eligibility +is checked against the actual processor graph generation again at job entry. +A concurrent graph edit can reject a job; effects are never removed or +substituted to make a track eligible. With fewer than two eligible tracks or +no helpers, the whole graph retains serial execution. + +Input/playback preparation and raw recording taps stay on the callback. +Prepared per-track audio/MIDI storage separates concurrent DSP. Each Rack +retains its sequential effect order. After the isolated NAM batch, the callback +processes other tracks serially and combines every completed output in the +existing dependency/summation order before master/monitoring processing. Work uses the **same +device block**, with **zero added buffering samples**. No smaller model, +automatic quality reduction, lower oversampling or extra monitoring block is +introduced. Windows helpers register for MMCSS individually. Event sleep is +the default; a bounded, opt-in polling interval is available to the diagnostic +probe for controlled wakeup comparisons, not as a user tone setting. The probe +also accepts a bounded recovery grace after the nominal collection deadline; +the app default remains zero, no extra audio buffer is added, and crossing the +nominal deadline still fails capacity. See Testing for the diagnostic flags. + +The engine pins the routing snapshot before publishing work and retains it +until every worker has stopped touching its jobs. The callback's collection +deadline includes a small final-mixing reserve, not a guarantee for arbitrary +remaining serial/master work; the outer callback meter still covers all of it. +The collection deadline does not interrupt native DSP +already running, including a job claimed by the callback itself. Unclaimed +jobs can be cancelled atomically. Completed lanes can still be mixed in order; +unfinished lanes produce no late output in a later block. If workers remain +active, every processor queued in that old batch stays quarantined until +ownership drains, including already-completed lanes. Unowned tracks continue +serial processing, sends, sidechains, recording and mixing in the existing +dependency order; no new parallel batch starts while the old one remains. +Raw armed-track recording for quarantined NAM lanes continues through a +separate callback-owned tap, without touching their processor buffers. That +tap retains incoming sends from the current routing snapshot, including a +send added after the old isolated batch was dispatched. A new route from a +quarantined source still receives silence during that overload; +unrelated existing routes retain their normal signal path. Stop/reprepare, offline +render and teardown drain workers off the audio thread. These containment +rules prevent unsafe buffer reuse; a cancelled, rejected or quarantined job +still fails capacity and can interrupt monitoring. + +The routing audit also corrected stopped-transport live monitoring: hardware +input now adds to an existing incoming-send mixture instead of overwriting +it. Release contracts verify stopped/playing Float32 and Hybrid64 routing, +pending-batch recording and actual PDC delay; these functional passes do not +qualify a low-buffer monitoring deadline. + +NAM Rack shows **Overload** and offers **Buffer** when recent worker failures +occur, even if the outer callback meter is low. Audio Settings reports the +same condition. Diagnostics retain deadline, rejected/cancelled job and +quarantine counts separately; they are overlapping evidence, not counts to +sum. A single recent worker failure warrants a warning, which clears after +ten seconds of callback progress without another failure. + +```mermaid +flowchart LR + A[Prepare inputs and raw recording taps] --> B[Track 1 on callback] + A --> C[Track 2 on worker] + A --> D[Track 3 on worker] + A --> E[Track 4 on worker] + B --> F[Combine in stable order] + C --> F + D --> F + E --> F + F --> G[Master and monitoring FX] + G --> H[Current device output block] +``` + +The feasibility gate is **serial preparation + dispatch/completion overhead + +longest dependent processing path + final mixing/master work < device period**, +with headroom for scheduling variation. Four workers do not divide every cost +by four. Existing single-Rack wall-time outliers also require investigation; +they do not yet distinguish intrinsic DSP cost from thread descheduling. +[Independent paths and critical paths](https://help.ableton.com/hc/en-us/articles/209067649-Multi-core-performance-in-Ableton-Live-FAQ) +explain the scheduling model, not a performance guarantee for OpenStudio. + +#### Regression and remaining acceptance gates + +The ownership rules above are maintenance invariants, not unfinished architecture. +Jobs follow `Idle -> Ready -> Running -> Done -> Idle`; completion must cover +processor access and scoped-guard teardown before storage is reused. Publish +the snapshot lease before dispatch, retain ownership through abandoned-job +retirement, and keep reclamation/joining off the callback. Do not add realtime +`shared_ptr` destruction, unbounded waits or a callback fallback that processes +a still-owned Rack twice. Each worker uses the same block position, rate, +transport/automation epoch and denormal handling as serial execution. + +| Area | Retained contract and required qualification | +| --- | --- | +| Reproduction and timing | Use explicit Bestest/Distortion and both recovered bass presets, independent inputs and resource/state hashes. Compare one/two/four tracks at 44.1/48 kHz and 4x, then bounded mono/stereo, 2x/8x and high-rate stress separately. The 48 kHz model rate avoids conversion but shortens 16/8-frame deadlines to 333.3/166.7 us. Include host preparation, longest Rack, dispatch/completion and reduction/master work. Preallocated per-job traces must separate callback/helper work, cancelled jobs and timing overhead; wall time still includes preemption. | +| Serial/parallel correctness | Preserve raw mono recording before monitor duplication/FX, punch/loop/latency compensation, PDC, mute/solo, sends, sidechains, pre-fader taps and Float32/Hybrid64 reduction. Compare matching event positions and summation order. `--realtime-track-self-test` exercises executor, graph eligibility and full-engine routing/recording/lifecycle contracts; current results belong in Testing. | +| Overload and lifecycle | Controlled delayed-worker/cancellation fixtures must prove exactly-once ownership, no stale output, no buffer reuse and continued unrelated recording/routing. Drain ownership before device restart, preset/model/IR replacement, track removal, offline render or destruction. Retain rapid edits, variable partitions and callback-allocation coverage. A rejected, skipped, cancelled or quarantined job fails capacity even when the outer callback fits. | +| Octaver and coefficient caches | Sample-boundary retirement and bypassed-profile settling are implemented. Preserve all six resampling phases, individual/both-voice off/on behavior, 1/5/6/7/19/31-sample off dwells, overlapping profile changes and Octaver-to-Fuzz transitions. Compare cached processing with forced coefficient redesign across reset/reprepare, off-edits and activation; preserve frozen unaffected output. | +| Pedal interactions | The discrete core is Octave off/on x Fuzz off/Vintage/Sustain/Gated x Drive off/on x Distortion off/Heavy/Extreme/Crunch: 64 configurations per fixed profile/control/input set. Add Guitar/Bass, conservative/stress levels, all Direct/Down/Up presence masks, zero/end/interior Clean and Distortion Mix, +6/+12/+18/+24 dBu calibration and parameter extremes. Pairwise coverage of global/pedal gates, compressor, PRE/post EQ, captured pedal, room/doubler and time effects bounds the larger space; it is not exhaustive continuous coverage. | +| Measured optimization | Investigate only demonstrated bottlenecks: dispatch/wakeup, cabinet bursts, matching mono histories, shared detector work or model kernels. One over-budget dependent Rack cannot be fixed by more workers. Drive/Distortion already share an oversampling island, mono input uses one NAM lane, and NAM shimmer is granular. Preserve signal/state/latency and safety cadence; do not substitute models, quality or bypassed stages. | +| Routing expansion | General dependency batches and other native/hosted plugins remain separate work. Require source-before-consumer scheduling, deterministic fan-in and no concurrent accumulation; preserve serial cycle behavior without a new feedback delay. Chain, diamond, shared-bus, sidechain, automated-send, mute/solo and PDC parity plus plugin thread-affinity qualification are prerequisites. | +| Sustained device acceptance | The four-track/16-sample target remains open. Require at least 30 minutes per required Debug and Release configuration with recording/monitoring, editors open, live controls and sustained tails; qualify 8 samples separately. Require zero callback/worker misses or skipped work, recording integrity, actual input routes and measured round-trip latency without new buffering. Unsupported driver counters are unknown, not zero. | +| Sound and recall | Keep unnormalized native renders and separate level-matched audition copies. Include real bass B0/E1, chords, palm mutes, quiet decays and guitar-volume cleanup through clean/crunch/high-gain captures; avoid an extra Cab on full-rig captures. Preserve old Fuzz-off states and project/preset/Compare/Undo/portable recall. Alias measurements require aligned high-rate references, linear-commutation and deliberately broken negative controls; residuals alone are not tone approval. | + +The legacy Bass-labelled payload that restores Guitar has an explicit reviewed +profile-copy workflow; names must never silently override deterministic state +migration. Original user files remain unchanged. Exact-artifact audition remains +the final decision for tone and continuity. + +The retained October 22 machine inspection found a Ryzen 9 7900X (12 cores, +24 logical processors), Windows Balanced power plan, Audient iD software +4.3.19 and USB driver package 5.1.0; the ASIO DLL reports 5.58.0.0, a different +version namespace. Audient's [changelog](https://support.audient.com/hc/en-us/articles/206634065-iD-Driver-Change-Log) +lists driver 5.2.3 in iD 4.3.20 and later software releases; installer changes +do not establish a latency fix. Driver and supported power-mode comparisons +remain controlled experiments. No driver, firmware or system setting was changed +by that inspection. + +Use the [test commands and retained results](testing.md#nam-implementation-qualification-october-22-2026) +for focused signal checks and the [multicore live matrix](testing.md#multicore-nam-live-qualification-october-23-2026) +for capacity. Report `pass`, `fail`, `diagnostic_only` and `not_asserted` +separately. No schema change or larger implicit buffer closes an unmet gate. + ### Reverb The active rack/instrument reverb is true stereo and offers four Voice choices: @@ -619,6 +1036,9 @@ during restore. ### State and recovery contract - A rack preset represents the complete creative tone. +- Metadata sidecars and portable preset JSON use checked atomic file writes. + A failed sidecar save is reported and remains available to retry; cached + browser metadata is not treated as proof that the file was saved. - 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 diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 0fcce4d..3ce4186 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -331,7 +331,7 @@ selects a separately published Linux archive without replacing the other platforms. `OPENSTUDIO_AI_RUNTIME_VERSION` is the combined catalog revision; installed status reads each archive's actual version from its runtime metadata. The published and qualified `ai-runtime-v0.0.16` uses Python 3.11.15. -The corrected 0.1.05 candidate selects catalog revision `0.0.16` and that tag +The published 0.1.05 release selects catalog revision `0.0.16` and that tag for Windows/macOS. Keep `ai-runtime-linux-v0.0.14` for the already-qualified Linux archive. The old Windows 0.0.13 base uses Python 3.10.20 and cannot satisfy Audio Generation; @@ -599,8 +599,10 @@ It is not proof that the API can adopt a portal draft. The initial path below is historical guidance and a guarded capability, not a verified live result. The `v0.1.04` application publication was stopped in Windows unattended uninstall qualification; no Store update was submitted. Its tag is retained. -The corrected `v0.1.05` candidate uses the published-baseline path and its -reviewed publishing policy. +The published `v0.1.05` release uses the published-baseline path and its reviewed +publishing policy. Both live preflights passed, but submission stopped before +commit when Partner Center changed a documented read-only pricing boolean. +The existing draft is preserved; see the [recorded diagnosis](store-release-activation.md). 1. Merge the release and any release-preparation follow-up only after CI passes. Validate `docs/releases/.md` on the final source, then push the stable @@ -681,6 +683,26 @@ On timeout/failure, the job records the submission ID and status without tokens or SAS upload URLs. Rerun failed jobs to resume the same release artifact. A new package with a different hash requires a new version, not an overwrite. +### Recover the published 0.1.05 Store draft + +The original application tag and assets are immutable. Rerunning its failed +submission job would still execute the old validator. After merging the reviewed +automation hotfix from `develop` and passing CI, tag that merged automation +revision `v-store-recovery-0.1.05-1`. This tag runs `store-recovery.yml`, which +uses the existing tag-only `microsoft-store` environment and serialized Store +submission concurrency. It does not run the numeric-tag application Release +workflow or rebuild application packages. + +`tools/resume_store_release.py` first verifies the published release, original +source/run/job outcomes, qualified package artifact and exact release notes. +It validates the original MSIX identity, version and SHA-256, then performs a +read-only preflight against the fixed published baseline and existing marked +draft. The submission step rechecks those conditions before upload and commit. +The API wrapper permits GETs of those fixed resources, upload of the verified +package, and commit of that draft; create, update and delete requests are refused. +The retained report distinguishes accepted ingestion from final certification +and publication. Inspect any failure before another recovery attempt. + ### Publication after certification The owner requested immediate release and automatic Store submission. After diff --git a/docs/releases/0.1.06.md b/docs/releases/0.1.06.md new file mode 100644 index 0000000..a64d538 --- /dev/null +++ b/docs/releases/0.1.06.md @@ -0,0 +1,114 @@ +# OpenStudio 0.1.06 + +This is an unpublished release candidate proposed on `fix-reverbs`. The +[candidate comparison since v0.1.05](https://github.com/sdevil7th/OpenStudio/compare/v0.1.05...fix-reverbs) +covers the application changes below and Store submission safeguards. The +[review baseline](https://github.com/sdevil7th/OpenStudio/compare/v0.1.05...e1cf8024bcc293f6b3ac2ad49caadf84c20193f8) +contains only the preceding Store work. Candidate features are not included in +the downloadable 0.1.05 installer. The final tag comparison and qualification +of its exact artifacts remain release gates. + +## Highlights + +- Give the free effects and instruments more useful compact and large editors: + primary controls stay accessible, wider views show related controls and + analysis, and Find locates controls and workflows without silently changing + an effect's machine or mode. Exact entry, keyboard adjustment, Compare and + Undo retain the native parameter identities. +- Add independent Octave and Fuzz switching in NAM Rack, Guitar/Bass processing + profiles and three original fuzz voices. Clean blend now lives on the pedal + with a knob and recessed percentage display. It blends the input to Fuzz; + subsequent Drive, Distortion and amp processing still apply. +- Process eligible independent native-NAM audio tracks across persistent + workers, scaling with available physical cores. Unsupported hosted-plugin, + MIDI and routed paths retain serial processing. This introduces no extra + device buffering, but does not yet meet the four-track, 16-sample target. + +## Fixes + +- Keep pending Audio Settings selections until Apply or OK instead of allowing + periodic device readback to replace a newly chosen buffer size. Report the + size actually accepted by the driver and distinguish recent callback and + track-processing failures from driver interruptions. +- Bound recording-waveform previews and serialize outstanding preview reads. + Dragging the ruler vertically zooms around its anchor without the recording + follow-scroll fighting the drag, including the interval between pressing + the ruler and crossing the drag threshold. With the default ruler mapping, + horizontal dragging sets the project/loop range; explicit Select time mappings retain + editing time selection. + Stop and cancelled gestures discard stale preview and viewport work. +- Reduce excessive gain at moderate Vintage/Sustain fuzz settings and stabilize + downstream Distortion calculations and the shared high-pass filter at high + oversampling. Maximum fuzz gain remains available. Stacked gain can still + amplify input hiss; these changes do not claim a commercial pedal emulation. +- Improve Reverb damping, Hold headroom, inactive-engine retirement and Shimmer + work scheduling. Response measurement waits for pending control writes, so + its isolated wet-only measurement uses the displayed settings. Preserve + compatibility paths and stored parameters across the free suite. +- Save NAM metadata, portable presets and Pitch Correct presets through a + bounded atomic JSON writer instead of the project-only writer. Failed saves + are reported without replacing the existing file or relaxing project checks. + Pitch presets and A/B retain the processing mode that gives their note mask + meaning; old presets without a mode recall Legacy behavior. +- Explain when NAM Rack reduces loud output peaks in plain language, separate + recent activity from earlier events, and retain distinct emergency-fault + messages. Numeric instrument controls do not change or add Undo history when + focused and left without an edit. +- Require the free-suite browser qualification to export schemas from the + current native build in Windows CI. An explicitly requested missing schema + now fails qualification rather than silently skipping those editor checks. +- Keep TONE3000 capture-loading errors visible in compact editors when a + request completes immediately after the details panel is collapsed. +- Preserve existing destination files when audio conversion finishes after + its deadline, including when the child process exits between polling checks. +- Correct ARA tempo and bar reader handles on 64-bit systems, and reject invalid + content types or event indices before reading event data. +- Make Linux desktop-entry packaging independent of the source checkout's line + endings. Create new automatic-update staging directories with private + permissions instead of inheriting group-write access and then rejecting + those directories. Existing unsafe paths remain rejected. +- Preserve Store listing settings when Microsoft changes read-only metadata, + and support validating and resuming the exact existing submission. Recovery + tags no longer trigger ordinary application release publication. + +## Known Issues + +- Four monitored, recording NAM tracks at 16 samples and 4x oversampling have + not passed the required sustained test. Retained short Debug and Release + probes contain missed deadlines or skipped work; 8 samples is also + unqualified. Average CPU readings and passing recording-file integrity checks + do not establish crackle-free monitoring. Oversampling is not lowered + automatically. +- Exact-artifact listening acceptance, physical audio/MIDI devices, long + recording sessions and mixed-DPI usability remain separate gates. Automated + audio checks establish specific signal, state and safety contracts, not + subjective fuzz, pitch or reverb quality. Demanding Shimmer and Pitch Correct + configurations retain measured deadline limits. +- Release-specific installed Windows upgrade/Repair, the affected Windows 10 + installation and current macOS package acceptance remain unverified. Linux + automated qualification is recorded in the testing guide; physical desktop + and audio-device acceptance of the published package remain separate gates. + Moving Linux downloads to native packages does not prove the original + AppImage reporter's failure resolved. Signing, reputation, Microsoft + certification and website/update-feed publication require the final artifact. +- Previously recorded third-party plugin export/state and automation issues + remain open in the roadmap, including AmpliTube cold-export parity, Nekobi + restoration and the native played-guitar Punch/AutoJoin timeout. This + candidate does not claim to resolve them. + +## Upgrade Notes + +- Back up projects and presets before trying this candidate. Use normal + in-place installation when a qualified update is published. On Windows, + uninstalling first removes user-scoped settings, optional runtimes and caches. +- Existing presets retain their compatibility mode. Newly saved Pitch presets + include that mode explicitly. Fuzz gain changes at moderate settings may + change sustain and the saturation of later pedals; audition saved rigs and + adjust Fuzz Level and the rack Gate intentionally. +- Select the distribution-native Linux package matching the host. Published + download and update endpoints continue to describe the actual released + artifacts; preparing this candidate does not update those feeds. +- The [testing guide](../testing.md), [NAM Rack guide](../nam-rack.md) and + [free-plugin guide](../free-plugins.md) hold the workflows and qualification + limits. Candidate build/test results must be tied to the tested source and + binary before release approval. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4910631..6b4ea84 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,9 +8,60 @@ lives in [Testing](testing.md) and the ## Now: release quality +- Qualify the implemented built-in suite editor and Convolution changes using the + [editor maintenance and qualification gates](free-plugins.md#editor-maintenance-and-qualification). + Design 03 is implemented in the working tree; review and qualification remain + in progress. Verify Find-to-control navigation, primary controls in compact + windows, useful wide overviews, exact adjustment and complete-state comparison. + Retain every native parameter, contextual machine control, IR/MIDI/routing + workflow and original automation identity. Qualify responsive geometry, + Release callback cost and effect-specific audio laws separately; neither a + screenshot nor a parameter inventory proves sound, CPU headroom or completion. +- Qualify the development-tree [standalone plugin changes](free-plugins.md) + through exact-artifact listening, live audio/MIDI devices and supported + platforms. [Retained evidence](testing.md#retained-audio-qualification-and-limits) + includes Shimmer and Pitch Correct callback-deadline failures at demanding + rates/buffers/instance counts and Original/Spread transition limits. Earlier + continuing-input Infinite Hold saturation is retained as a reproduced + baseline; the subsequent implementation passes all 16 bounded headroom + fixtures on the final Release build. Indefinite accumulation and listening + acceptance remain unasserted. Deterministic passes do not close the separate + performance, device or listening limits. - 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. +- Qualify Octaver Fuzz as part of the complete guitar rig: preserve independent + octave tracking, raw clean blend and old-state fuzz-off recall; verify the + shared interface calibration convention, then audition the expanded + Octave/Fuzz/Drive/Distortion matrix through explicitly identified NAM amp and + cabinet fixtures at conservative and stress settings. The working-tree + 531-case full-chain matrix passes structural checks; this is not listening + acceptance. Keep exact native + before/after float WAVs and resource/source hashes. The + [October 23 Swollen Pickle reference review](testing.md#swollen-pickle-reference-fuzz-review-october-23-2026) + implements moderate-gain refinement, an integrated Clean control and numerical + corrections in downstream Distortion/Saturator; exact-artifact listening is + still required. Use the + [artist research and scope](nam-rack.md#full-rig-fuzz-qualification-october-18-2026) + to guide one audible change at a time after baseline capture; no commercial + replica or preferred tone is established by measurements. Follow the + [NAM CPU and interaction contract](nam-rack.md#cpu-and-pedal-interaction-contract) + for implemented sample-boundary Octaver retirement and coefficient caching, + remaining two-Rack deadline limits, both recovered bass-preset scenarios and + broader pedal coverage. The Audient 8-to-16 buffer transaction has passed a + silent hardware test; that does not qualify a loaded two-track session. + Qualify the implemented [multicore track executor](nam-rack.md#four-track-live-monitoring-acceptance-target) + and address measured longest-track and wakeup bottlenecks. Persistent helpers + scale with physical cores; isolated native-NAM audio tracks can run in + parallel while hosted, MIDI/ARA and routed tracks retain serial processing. + The conservative eligibility gate adds no device buffering. The required target is four recording, + monitored NAM Racks at 16 samples and 4x Drive oversampling; 8 samples is a + separate stretch target. Bounded Release probes show lower average callback + costs, but four- and twelve-track cases still report missed or skipped work. The target + remains **not met**; bounded ownership/recording tests and a low average CPU + meter do not close the live deadline, recording-duration or listening gates. + Keep measured results and exact test scope in + [multicore qualification](testing.md#multicore-nam-live-qualification-october-23-2026). - 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, diff --git a/docs/store-release-activation.md b/docs/store-release-activation.md index fc0ce7a..5cb81a2 100644 --- a/docs/store-release-activation.md +++ b/docs/store-release-activation.md @@ -19,10 +19,32 @@ The hotfix makes script dialogs suppressible with explicit safe defaults and returns a nonzero installer result for failed post-install validation. Actual compiled unattended installer/uninstaller regressions run before Windows builds. Installed-release CI qualification is bounded and retains its diagnostic logs. -The follow-up `v0.1.05` policy requests `targetPublishMode=Immediate` for Store -package `0.1.5.0`, retaining the owner's automatic-publication intent. All live -API access, state, upload/commit, certification and Store-installed upgrade -checks still require evidence from that release; none is asserted here. +The follow-up [v0.1.05 application release](https://github.com/sdevil7th/OpenStudio/releases/tag/v0.1.05) +was published from `649012c77f86eb9cbfee616bd16a588576c63112`. All three platform +release jobs passed, including actual installed Windows lifecycle and silent +uninstall, and the website deployed the matching update feeds. Both live Store +preflights authenticated and passed for package `0.1.5.0` with publication mode +`Immediate`. + +The automatic submission created draft `1152921505702075647`, then its read-back +preservation guard stopped before commit. A hash-verified, GET-only diagnostic +found one preserved-settings difference: Partner Center changed the read-only +`pricing.isAdvancedPricingModel` boolean. Identity, ownership marker, release +notes, actual pricing fields and publication mode matched. The server also +omitted the deleted old package's optional `targetPlatform` metadata. The draft +remains intact. Upload completion, certification and Store-installed upgrade +are not asserted from this failed attempt. + +The automation follow-up normalizes only the documented read-only pricing +boolean and tolerates only the observed missing metadata on a deleted package; +changed prices, unknown fields, retained packages and present changed metadata +still fail. Its separate recovery workflow reuses the exact qualified MSIX from +run `37790026737` and can only resume the existing draft. It cannot create, +update or delete submissions or replace application release assets. A reviewed +`v-store-recovery-0.1.05-` tag triggers that workflow; numeric +application tags trigger the application Release workflow. Both use the existing +tag-only Store environment. The diagnostic's temporary exact-develop access +was removed immediately after it completed. The readiness check below records the earlier 0.1.04 preparation. Its tag/policy statements are historical; the current policy file pins only the reviewed diff --git a/docs/testing.md b/docs/testing.md index 68ca669..41b2f55 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,5 +1,1283 @@ # Audio and Runtime QA +## 0.1.06 candidate review (October 23, 2026) + +The release candidate remains **not approved for publication**. Its reviewed +changes are described in [0.1.06 notes](releases/0.1.06.md); those notes distinguish +the proposed application candidate from the Store-only review baseline and the +published installer. +The following closes code-review findings, not the remaining hardware and +platform acceptance gates: + +- NAM metadata/portable and Pitch presets use a bounded, strict JSON document + writer with checked temporary writes and atomic replacement. Project-only + validation and recovery remain separate. POSIX publication uses same-directory + rename with no copy fallback; the permission-failure fixture must run as an + unprivileged user. Windows execution does not qualify that POSIX branch. +- Pitch custom presets and A/B capture the processing mode and every exposed + control. Legacy imports retain root-relative masks; factory tonal templates + preserve the current mode. Recall uses one guarded native transaction and + verifies target identity. File failures and stale dialog completions are + covered separately. +- NAM's output-protection message distinguishes recent reductions from history, + wraps at compact/4K sizes and retains emergency-fault priority. A reported + final-output zero is authoritative; older model-stage counters receive + Input/Level guidance instead. Untouched instrument number fields retain full + precision and do not create Undo entries. +- Windows CI exports editor schemas from its current Release executable and + runs the free-suite browser qualification. Explicitly requested missing + schemas fail instead of skipping. Windows and macOS run the native file/runtime + contracts on their Release executables; Linux's feature qualification already + runs those contracts. These workflow definitions still require an actual CI run. + +The macOS conversion fixtures require system FFmpeg on `PATH`; CI installs it +when absent and prints its version before building. This is a test prerequisite, +not bundled-runtime qualification. Native reports include an explicit FFmpeg +availability check and conversion failure diagnostics. The Windows free-suite +launcher uses the first npm application on `PATH` and propagates its native exit +code; `tests.test_free_suite_ui_regression` covers multiple installations, +failure propagation and environment restoration. Input fixtures choose the +primary modifier from the browser's platform identity (Command on macOS), while +asserting shortcut ownership separately for top-row and numpad keys. +Linux native CI prefers the hosted image's official Ubuntu HTTPS archive +fallbacks after observed Azure-mirror download stalls. Its dependency step has +a 15-minute backstop and still fails on installation errors; the developer +prerequisite script does not change the machine's mirror configuration. +Conversion deadline qualification keeps a real FFmpeg child, checks that a +timed-out operation preserves the destination and removes its staging file, +and resets each negative fixture independently. The deadline is checked after +process exit and immediately before publication as well as during polling. + +Before the SSH Linux qualification below, the local candidate passed 2,989 +frontend unit tests, 70 selected Chromium +browser cases, 185 free-plugin regression groups, 264 render/export checks, +and 369 Release / 370 Debug runtime checks. A deliberately missing native +schema failed the browser qualification, as required. The free-plugin run preceded +the final file-writer-only rebuild; its DSP sources were unchanged. Objective +signal/state results are **pass**, timing measurements are **diagnostic_only**, +and subjective audio quality is **not_asserted**. Full frontend units, focused +browser qualification, types and build evidence live under +`output/review/release-0106*`; this ignored evidence is not a published release. +Production and browser-test TypeScript checks pass. Both 0.1.06 native builds +compiled without C++ warnings; all 146 packaged frontend files match the final +production bundle. Vite still reports its large-chunk and ineffective-dynamic- +import advisories. That checkpoint's evidence index is +`output/review/release-0106/qualification-summary.json`. + +Fresh silent Audient probes used the exact 0.1.06 Release executable SHA-256 +`9f5ae9ca1a2488a3d4a0644c47797eb8b6433918719aed4f0c9dd228133faaf0`, +four recording/monitored tracks alternating Bestest clean with Distortion and +the recovered Bass Clean preset, 4x oversampling, automatic workers, three +seconds warmup and ten seconds measurement. No polling or recovery grace was +enabled. Both cases **fail capacity**: + +| Rate / buffer | Mean / p99 / max callback (us) | Callback misses | Worker misses / quarantines | +| --- | --- | --- | --- | +| 48 kHz / 16 | 107.8 / 234.4 / 429.2 | 18 / 30,000 measured | 64 / 29 | +| 44.1 kHz / 16 | 114.0 / 248.0 / 553.8 | 35 / 27,563 measured | 83 / 50 | + +Worker counters include warmup and are not additive with callback misses. +All eight recorded WAVs pass exact frame counts and PCM parity within two +16-bit LSBs, with zero write failures. Driver/settings restoration passes; +driver xrun readback is unsupported (`-1`). Physical input, audible continuity +and 30-minute stability are **not_asserted**. See +`output/review/release-0106/cpu-short-summary.json` and the earlier multicore +qualification below; a lower average with skipped work is not acceptance. + +The next performance step is an opt-in, preallocated per-job trace of dispatch, +start and completion, read only after device removal and executor drain. It +must distinguish callback/helper jobs and cancelled or unfinished work, preserve +default scheduling and report overflow without dropping DSP. Compare timing-off +and timing-on runs: timestamps perturb the measurement, and processing wall time +still includes preemption. Investigate wake/queue delay versus rack execution +before choosing a scheduler or DSP change. Only a clean short run warrants the +required sustained four-track qualification; do not hide the failures with a +larger implicit buffer or lower oversampling. + +The local Windows RC command passed the version, runtime-bundle and prerequisite +self-test gates but stopped at installer construction because Inno Setup is +unavailable. Installed normal/safe startup, upgrade/Repair on the affected +Windows 10 installation, current macOS packaging, exact-artifact audition, +signing and final source/tag CI remain release gates. The sibling website has +a new development article, `blogs/2026-10-23-tuning-openstudio-free-effects.md`; +its build/lint and all 239 website tests passed. It was uncommitted at that checkpoint, +and public update feeds still describe the released version. + +The final build-tree Release passes all four automated window lifecycle cases +under the current standard-user Windows account: normal and safe mode, each +with clean and invalid inherited runtime paths. Each normal case passes 99 +checks and each safe case 90, with packaged frontend readiness and zero hardware +device starts/callbacks. Exact binary/frontend hashes and owned-process cleanup +are retained in the adjacent `windows-*.qualification.json` reports. These are +build-tree checks: the installed artifact, protected Program Files location, +Windows 10 Repair, visual inspection and physical audio remain unasserted. + +### SSH Linux qualification of the working tree + +The October 23 run transferred a file-hashed source snapshot to an isolated +directory on the connected Ubuntu 26.04 machine. It did not push an unfinished +branch or change the existing remote checkout. Ubuntu 22.04 baseline builds and +installation tests use a disposable VM overlay; the physical host's installed +OpenStudio is not upgraded. Source manifests, amendments, executable hashes and +logs live under `output/review/release-0106/linux-20261023` and the remote +`/home/sayak/dev_work/openstudio-qa-evidence/0.1.06-20261023` directory. + +Fresh Linux dependency installation exposed missing optional WASM lockfile +entries; the repair preserves existing dependency versions and integrity hashes. +The production frontend build, both TypeScript checks, all 2,989 unit tests and +70 Chromium interaction/geometry cases pass on Linux. A document-owned module +bootstrap fixes a garbage-collected test-fixture promise without changing app +behavior. System WebKitGTK 2.52.6 passes 44 real-input control checks against the +production frontend and mock bridge. Separately bundled Playwright WebKit cannot +launch because its `libavif.so.16` dependency is absent; none of that runner's app +assertions executed. System WebKit coverage is independent, not a relabelled +Playwright pass. + +Both native Release builds compile with zero warnings: GCC 11 on Ubuntu 22.04 +and GCC 15 on Ubuntu 26.04. Portability fixes retain the DSP calculations and +executor storage layout. Narrow suppressions cover reviewed false positives in +pinned YSFX vendor files only. Linux compilation also exposed an actual ARA +host-handle conversion reading eight bytes from a four-byte value. Handles now +use a pointer-width integer, unsupported content is rejected, and event reads +validate their bounds. All seven new ARA contracts pass on both Linux builds and +Windows Debug/Release; commercial ARA plugin integration is not asserted by +these contracts. + +The full free-suite run exposed a Windows-specific golden-file assumption: +procedurally generated default convolution IRs use `expf`, whose final float +rounding differs across math libraries. All 1,030 properties in the compared +preset match except that IR; 89 of 115,200 samples differ, by at most +4.66e-10. The portable regression preserves exact historical serialization of +all other properties, validates the complete historical IR recipe within two +float ULPs, and checks malformed/corrupt negative controls. Original Windows +full-state hashes and same-platform byte-exact round trips remain enforced. +This is a test portability correction, not a DSP or preset-format change. + +Package staging now writes desktop entries with LF line endings, including +source snapshots taken from Windows. Updater checks also exposed newly created +directories inheriting group-write permission under `umask 0002`, then failing +the installer's existing trust checks. New staging directories use private +creation modes; existing insecure ancestors and symlinks remain rejected and +are not silently chmodded. Native DEB updates continue to use the package +manager, rather than the automatic AppImage replacement path. + +Each Linux native build passes resource lookup in three launch conditions, +85 main/detached window and objective pitch checks, 264 render/export checks, +360 runtime-safety checks and all 40 engine-feature suites. These use actual +WebKitGTK in Xvfb and unprivileged test accounts. POSIX atomic-publication denial +is asserted, and the qualification tool now fails closed on missing, malformed, +failed or unasserted permission evidence. No JUCE assertion/leak diagnostics were +reported. Xvfb does not qualify the physical desktop, native Wayland, audio +hardware, long sessions or subjective sound. + +The final Ubuntu 22.04 DEB upgrades the disposable VM from 0.1.04-14 to +0.1.06-1. Installed normal startup passes the full native checks above; safe +startup passes 76 lifecycle checks. Actual frontend boot succeeds through the +`/usr/bin/OpenStudio` symlink from an unrelated directory with stale `APPDIR` +and `APPIMAGE`. Removal and reinstall pass, preserving the test project, media +and preset byte-for-byte at every stage. This VM already has runtime dependencies; +it is not a fresh-OS installation test. The same extracted DEB payload passes +the full native checks on Ubuntu 26.04, without replacing the host installation. +Both Ubuntu environments pass all 185 free-plugin groups. The final baseline +ABI gate passes glibc 2.35, and both native builds pass 31 updater and 27 installer +contracts, including the permissive-umask regression without an external workaround. + +The unpublished DEB is `OpenStudio-0.1.06-1-ubuntu-22.04-amd64.deb`, SHA-256 +`39d20e5ecfb37d03a53a2426bb8a828afc62ceff7b652f5f63b0fbc889a563b8`; +its executable is `549ed9e61762a6b6140d72eaa9158a19f664b0c8043d943ff50b111874fdd3c3`. +The separately compiled Ubuntu 26.04 executable is +`f7ccec013fa7add1e99b559a0a7d74f770338f76345678994265d855f42b2423`. +Final schema export is byte-identical to the schema used by the editor tests. +The retained source manifest records all amendments to the initial snapshot. +Qualification preceded the candidate commit; subsequent documentation cleanup +and PR preparation leave the qualified non-documentation source unchanged. + +After these changes, Windows Debug and Release rebuilds have zero warnings, +377 and 376 runtime checks pass respectively, and all 146 packaged frontend +files match the production bundle. Release parallel processing passes 12 +executor checks, 36 engine checks and 96 parity blocks; CLAP automation passes +all 13 component gates. This offline verification does not supersede the +retained failing 16-sample hardware measurements above. + +### Open GitHub issue scenarios + +All four open issue bodies and their comments were re-read on October 23. +Scenario evidence is indexed in +`output/review/release-0106/linux-20261023/github-issue-scenarios.json`. +No issue was closed or represented as universally resolved by these checks. + +- [#25](https://github.com/sdevil7th/OpenStudio/issues/25): the actual Windows + Release stays maximized with identical native bounds when one click opens + Options > Preferences and when one click opens Insert > Media File's native + chooser; cancelling also preserves maximization. The physical display is + 3840x2160 at 150%. A separate 1920x1200 CSS-pixel browser regression passes, + but uses a mock bridge and does not substitute for that exact physical monitor. +- [#24](https://github.com/sdevil7th/OpenStudio/issues/24): the attached log + already used LocalAppData. Its failure was an in-process CUDA import followed + by a CPU/DirectML fallback modifying a loaded ONNX DLL in the same runtime. + An actual Windows loaded-DLL fixture reproduces WinError 5 and verifies the + candidate-clone and atomic activation helpers can update a separate runtime + while the old DLL stays loaded. Nine mechanism checks, 21 focused Windows + installer tests, 17 Linux tests and five Linux filesystem checks pass. Pip, + CUDA/DirectML installation and real GPU separation are not exercised by that + mechanism fixture. The report's WAV-to-stems flow and Edge download-warning + follow-up remain separate acceptance work. +- [#7](https://github.com/sdevil7th/OpenStudio/issues/7): the final Windows 11 + Release passes normal/safe startup with an injected invalid WebView2 runtime + override (100/90 checks, all ten frontend documents ready once, no audio + device starts). Installed Windows 10 22H2 startup after Repair Dependencies + remains untested. Linux WebKit success cannot close a WebView2 failure. +- [#23](https://github.com/sdevil7th/OpenStudio/issues/23): installed DEB tests + exercise the original resource-location mechanism. A finished AppImage also + reaches main/detached frontend readiness on Ubuntu 22.04 under Xvfb/Openbox; + this uses extract-and-run, not a FUSE mount. Its SHA-256 is + `be9bcd3ac67e1f2ca1a23b8e51a468d7d639432df76f20cf92a95b4e735ac030`. + Packaging uses the same baseline build and pinned linuxdeploy, with only + CRLF-to-LF normalization of the copied shell script/desktop entry recorded + in the input manifest. The literal Arch/fish environment and unidentified + newer build mentioned on October 21 remain unverified. + +The Linux scheduler needs separate measurement: the current helper's JUCE +`Priority::highest` request maps to ordinary `SCHED_OTHER` on Linux, unlike the +explicit Windows MMCSS path. Record the effective callback and worker policies +for the active backend before changing them. A platform-supported real-time +startup path needs permission-aware fallback and diagnostics; assigning maximum +FIFO priority indiscriminately is not an accepted solution. Combine that audit +with the dispatch/start/finish trace above to separate scheduler delay from DSP +cost. Linux VM timing is **diagnostic_only**, not evidence of 16-sample capacity. + +## Octaver Fuzz checks + +Run the headless interaction suite after building Debug: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/run-nam-fuzz-review.ps1 -SkipBuild +``` + +Without `-SkipBuild`, the wrapper builds Debug first. `-GuitarInput` and +`-BassInput` accept explicit local recordings; otherwise it uses verified local +guitar fixtures or labelled synthetic fallbacks. `-OutputRoot` changes the +default ignored `output/nam-fuzz-review` directory. The runner restores its +process environment. In the October 18 development implementation, the focused +Fuzz and two-Rack CPU branches construct an explicitly offline AudioEngine and +assert no opened device, device starts or registered audio callbacks. This +does not describe the older unfocused NAM headless branch, which can restore a +saved audio device. A hidden window alone never proves device-free execution. + +`OPENSTUDIO_NAM_FUZZ_ONLY=1` selects focused contracts; heavy interaction sweeps +and WAV exports additionally require `OPENSTUDIO_NAM_FUZZ_AUDIT_DIR`. Ordinary +regressions do not export listening files. Native checks cover exact bypass, +full-clean parity, silence/recovery, stereo independence, reset/partition parity, +Bass gate transitions, route execution, and native/portable/Compare/bridge recall +at 44.1/48/96 kHz. Octave metadata is compared with an independent float32 ramp +timeline; requesting metadata must leave audio unchanged. Formula rounding +residuals are diagnostic, not permission to change that coefficient law. + +The retained October 15, 2026 refinement evidence is +`output/nam-fuzz-refinement/final/20261015_142950_fuzz-interaction/nam_rack_regression_result.json` +and `final/fuzz-5f3ff60f/manifest.json` under the same refinement root. +Structural/recall contracts and the 144-case boundary sweep passed. The complete +NAM regression subsequently reported 196 `pass`, 18 `diagnostic_only`, +8 `not_asserted` and no `fail` checks. These are dated runs, not certification +of later edits. + +October 15 diagnostic limits included pre-Amp peaks above unity in 52/144 extreme +corners (maximum about 3.89), bright 10 kHz partial-blend cancellation up to +12.5 dB relative to incoherent fundamental power, and source-dependent voice +levels. They do not establish a full-rack output failure or an audible defect. +Pedal-only DI lacks amp/cab processing; bass is synthetic unless its source +manifest identifies a supplied recording. Chord clarity, tone, alias audibility +and tail preference remain `not_asserted` until exact-artifact audition. + +`tools/prepare_nam_fuzz_refinement.py --after --output ` +pairs current exports with the archived baseline without output normalization. +Dry/octave-only files must remain byte-identical. Clean50 legacy files are +labelled reconstructions from native wet/octave endpoints under the original +linear law, rather than independent old-build renders. Override `--baseline` +when using another retained archive. Production DSP never reads these inputs. + +For the full-rig matrix, pass both explicit existing local resources: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/run-nam-fuzz-review.ps1 -SkipBuild -OutputRoot output/nam-fuzz-stack/baseline -AmpModel '' -CabIR '' +``` + +The `fullChainListening` manifest array contains all eight Fuzz/Drive/Distortion +on/off combinations, two control sets and every enabled Fuzz voice: 32 WAVs per +material at 48 kHz / 512 samples. Octave is active throughout, with +Direct/Down/Up `1/0.32/0.18` for conservative controls and `1.25/1/1` for stress. +The complete native Rack processes the explicit NAM amp and cabinet IR; +compressor, captured pedal, PRE/post EQ, room/doubler and time effects are +bypassed. The rig-only mask therefore retains Octave, amp and cabinet. Full +native state, resource hashes/capture metadata and input provenance accompany +each row. Existing arranged CC0 guitar notes are not a continuous performed +riff; synthetic Bass remains labelled, including when it passes through a +guitar amp fixture. Successfully loaded files do not certify their authorship, +recording chain, calibration or suitability for every instrument. + +Absent resource arguments produce `not_asserted` coverage; supplying one or an +invalid resource fails. Amp+cab captures are rejected for this separate-IR +matrix. Exported IEEE float32 audio retains native levels and the production +Rack's own safety processing, with no added normalization, limiter or gain +matching. Repeat with the same source/resources under an `after` output root, +then pass both manifests to `prepare_nam_fuzz_refinement.py --baseline ... +--after ... --output output/nam-fuzz-stack/comparison`. It requires matching +coverage, complete Rack controls and resource/input identity, and byte-identical +fuzz-off chains. Exact baseline Clean50 exports are copied; the older endpoint +reconstruction is used only when that baseline file is absent. A default ++12 dBu capture after a calibration-only change retains the old operating gain, +but is not an independently rendered old-build full-chain archive. +For the bounded Guitar/Vintage interstage-coupling candidate, add +`--expect-vintage-only`: Bass and every Sustain/Gated export must also remain +byte-identical. Capture this candidate's baseline after calibration qualification +and before the voicing change; do not substitute the older pedal-only archive. + +Calibration checks cover equivalent physical inputs at +6/+12/+18/+24 dBu +interface references, Guitar/Bass, all three voices and Clean 0/0.5/1, at +44.1/48/96 kHz. Common physical-domain output has an explicit `1e-5` absolute +sample tolerance; callback partitioning and full-clean digital passthrough are +exact. Live calibration changes and malformed-value fallback must stay finite +and partition invariant. These are functional contracts, not tone approval. +The unchanged-controls cache also has a reprepare contract: after warming at +44.1 kHz, prepare the same processor at 44.1/48/96/44.1 kHz without changing any +control, including +18 dBu calibration, and require exact fresh-processor output +for all three voices in Guitar/Bass. Its 24 cases include differing callback +partitions, protecting rate-dependent coefficient invalidation and reset state. + +The final October 18 **Debug** focused report is +`output/nam-fuzz-stack/final/20261018_032034_fuzz-interaction/nam_rack_regression_result.json`: +16 `pass`, 5 `diagnostic_only`, 1 `not_asserted`, no failures. Calibration, +unchanged-control reprepare, recall and all 96 full-chain integrity cases passed, +with no opened device, device starts or audio callbacks. Exports and exact +input/resource provenance are in `final/fuzz-dfa8ee23/manifest.json` under the +stack root: 33 pedal-stage and 96 full-chain native float WAVs. All 129 are +byte-identical to the corresponding exports listed in the earlier candidate's +`after/fuzz-2bc8505e/manifest.json`, +despite the additional resampler-indexing and inactive-Delay optimizations. +That candidate report remains at +`after/20261018_024158_fuzz-interaction/nam_rack_regression_result.json` under +the same root. Guitar/Vintage's coupling contribution ramps over 25 ms on +instrument-profile changes. These signal passes do not approve tone, establish +a live-device crackle fix or qualify two-Rack CPU timing. + +The exact comparison in `output/nam-fuzz-stack/final-comparison/comparison.json` +passed all 129 pairs with `--expect-vintage-only`: 107 WAVs are byte-identical +and 22 Guitar/Vintage WAVs differ. All Bass, Sustain/Gated, dry/octave-only and +fuzz-off full-chain exports retain byte parity, with matching controls, input +samples and amp/IR hashes. The files retain native float32 levels; there is no +output loudness matching or normalization. This is a scoped change/compatibility +pass against `offline-baseline/fuzz-20ee0b76/manifest.json`; preferred tone and +physical-device playback remain `not_asserted`. The prior candidate comparison +is retained at `output/nam-fuzz-stack/comparison/comparison.json`. + +The strengthened full-chain contract additionally requires exact loaded/enabled +amp/IR readback, positive NAM DSP frames on the first or final callback, an +active external-cab snapshot witness, expected processed block count/capacity, +finite nonzero output and no model failure, resize/oversize bypass, snapshot +fallback, realtime safety bypass, filter recovery or emergency mute. The Cab +witness establishes active resource availability with the prepared buffers; +it is not a separate convolution-call counter. Each row records these integrity +checks. Ordinary output-guard hits remain `diagnostic_only` and preserve native +levels. The final run recorded zero such hits and a maximum sample peak of +`0.297531843185425`; these are diagnostic observations for the exact fixtures, +not a universal headroom or audible-quality guarantee. + +Two-instance timing is a separate opt-in headless diagnostic. After a coordinated +native build, set `OPENSTUDIO_NAM_TWO_RACK_CPU_ONLY=1` and optionally +`OPENSTUDIO_NAM_TWO_RACK_CPU_BLOCKS` (128-16384, default 1024), then invoke +`OpenStudio.exe --nam-rack-regression-headless `. +It covers two independent serial complete Racks at 44.1/48 kHz, 8/16/32/64/128 +samples, mono/stereo, and amp/cab, complete, and complete-with-fuzz configurations +(60 cases). Record build configuration, exact model hashes, callback percentiles +and deadline misses. Optional `OPENSTUDIO_NAM_BENCHMARK_AMP_MODEL` and +`OPENSTUDIO_NAM_BENCHMARK_PEDAL_MODEL` select explicit local captures; `_2` +variants select the second Rack's captures. The default bundled A1/A2 examples +are diagnostic fixtures. These timings exclude physical ASIO/device scheduling, +GUI and host contention: `diagnostic_only`, with subjective sound quality and +real two-track monitoring `not_asserted` until tested. These commands describe +coverage; they are not a claim that the current checkout has passed it. + +Before the CPU matrix, the current implementation gates two output-preservation +contracts: `nam_resampler_contiguous_history_reference_parity` compares the +optimized contiguous-ring lookup with the frozen prior per-tap reference, +including ordered double accumulation, integer/fractional rates, startup/wrap, +partitions, resets, nonfinite input and capacity failures; +`nam_inactive_delay_macro_bypass_contract` checks exact disabled dry output, +off-edited engagement against a fresh processor and normal tail draining. +Failure stops the timing matrix. The earlier timing reports below predate +these additional gates. + +For only these cheap contracts, set both `OPENSTUDIO_NAM_TWO_RACK_CPU_ONLY=1` +and `OPENSTUDIO_NAM_TWO_RACK_CPU_CONTRACTS_ONLY=1`, then use the same headless +command or `tools/run-nam-rack-headless-regression.ps1 -SkipBuild -OutputRoot +`. The contracts-only flag by itself does not +select focused offline startup; it requires CPU_ONLY. No model, timing matrix, +physical device or app window is required by this focused route. Clear the +contracts-only flag to run the full matrix with both gates first. + +The October 18 **Debug optimization-contracts** report is +`output/nam-two-rack/final/20261018_032018_optimization-contracts/nam_rack_regression_result.json`. +The frozen-reference resampler comparison passed bit-exact output, canary, +history/cursor and phase parity: 8 rate pairs, 4 callback partitions and 2 reset +passes, totalling 75,264 calls and 418,696 compared samples, including malformed +input and capacity-failure cases. All 6 inactive-Delay lifecycle cases passed. +The report records no opened device, starts or audio callbacks. These are +deterministic `pass` contracts, not timing, hardware or tone qualification. +The same development validation's 344-check native Debug runtime suite passed +under `output/review/runtime-Debug-20261018-032025-491e3c`; CrashTests were +intentionally skipped. This does not establish Release behavior or actual ASIO +recovery on the user's device. +Settings validation also passed 18 browser cases, strict E2E types, 9 focused +unit tests and the frontend build. Restart recovery and explicitly disabled +endpoints were verified with mock drivers; physical ASIO behavior remains +`not_asserted`. + +The final October 18 **Debug CPU** report is +`output/nam-two-rack/final/20261018_032356_two-rack-1024-final/nam_rack_regression_result.json`, +with executable identity in sibling `executable-evidence.json`. All 60 integrity +cases and both optimization contracts passed; `audioDeviceOpened=false`, +`audioDeviceStartCount=0` and `audioCallbackCount=0` establish no hardware opens. +The 73.624-second run retained these `diagnostic_only` callback measurements; +each cell gives mean / p99 microseconds / misses among 1024 callbacks: + +| Rate / buffer | Two-Rack fixture | Mono | Stereo | +| --- | --- | --- | --- | +| 44.1 kHz / 8 | Amp/cab | 107.8 / 183.3 / 13 | 132.1 / 208.1 / 35 | +| 44.1 kHz / 8 | Complete | 251.7 / 480.5 / 1024 | 332.3 / 411.1 / 1024 | +| 44.1 kHz / 8 | Complete + Fuzz | 243.6 / 343.9 / 1024 | 332.1 / 425.6 / 1024 | +| 48 kHz / 8 | Amp/cab | 106.4 / 175.2 / 49 | 125.4 / 199.3 / 35 | + +Complete stereo, with and without Fuzz, missed every deadline at every tested +buffer from 8 through 128 samples. Amp/cab stereo was miss-free from 32 samples +upward in this run. Individual output peak `0.998689` and coherent two-Rack sum +`1.997379` are diagnostic level observations; no emergency mute occurred. The +matrix excludes the host/master graph, actual ASIO scheduling, the user's exact +models/settings and Release performance. Substantial nonstationary variation +across the matched captures supports no broad speedup or regression claim. +These deadline failures remain despite passing signal-integrity gates; live +crackle, physical-device stability and tone remain `not_asserted`. + +Earlier matched evidence is retained at +`output/nam-two-rack/baseline/20261018_023137_two-rack-1024-baseline/nam_rack_regression_result.json` +and `output/nam-two-rack/after/20261018_024429_two-rack-1024-after/nam_rack_regression_result.json`, +with their executable evidence. Their case identities/model hashes match the +final run; the baseline's 43/62 amp/cab misses at 44.1 kHz / 8 and the earlier +after-run's 11/35 demonstrate why noisy single runs cannot establish an overall +performance improvement. + +### Swollen Pickle reference fuzz review, October 23, 2026 + +This is working-tree evidence against base app commit +`e1cf8024bcc293f6b3ac2ad49caadf84c20193f8`, not a shipped release or a +commercial-pedal match. Lasting control/routing guidance belongs in +[NAM Rack](nam-rack.md#full-rig-fuzz-qualification-october-18-2026) and the +[manual](USER_MANUAL.md); generated reports, screenshots and WAVs stay under +ignored `output/review/fuzz-20261023/` and +`output/review/nam-fuzz-clean-20261023/`. + +The reference is the manufacturer's Swollen Pickle MkIIS/MkIII control +manuals, not an inferred circuit clone. Sustain/Filter/Scoop/Crunch and the +internal Voice/Clip controls describe a broad family of sounds; a noise figure +measured in A-weighted analog dBV cannot be compared directly with our +unweighted digital dBFS fixture. The official instructional video was located, +but playback was unavailable and no listening comparison to it is asserted. + +**Gain change:** Vintage and Sustain now use `1.5 + 73.5 * drive^4` +excitation rather than `5 + 70 * drive^2`. This reduces small-signal gain at +moderate settings while preserving the 75x maximum. Gated keeps its original +law. No automatic gain matching, extra output trim, new gate, filter, +oversampling island, added latency or state-version change was introduced. +Lower gain also changes sustain and harmonic balance; it is not free noise +reduction. Clean remains the raw input blended before pedal Level and the +later Drive/Distortion stages, now exposed as an on-pedal knob with exact +entry, reset, keyboard adjustment and Undo. Its percentage and Voice both +use black readouts. + +The optional `OPENSTUDIO_NAM_FUZZ_SIGNAL_AUDIT=1` branch adds 270 +standalone noise/note cases, 90 low-level sine cases and 64 pedal-cascade +cases. Set `OPENSTUDIO_NAM_FUZZ_ONLY=1` and run +`tools/run-nam-rack-headless-regression.ps1 -SkipBuild -AppPath ` +with an isolated output root. The newer gate/partition checks separately +exercise all eight Fuzz/Drive/Distortion masks; they must not turn a failing +whole-chain comparison into a passing Gate claim. All branches remain offline. + +| Observation | Interpretation | +| --- | --- | +| Guitar, Drive/Tone 0.5, seeded input noise -72 dBFS RMS: Vintage output -42.90 to -54.24 dBFS; Sustain -45.13 to -56.48 | `diagnostic_only`: about 11.35 dB less amplified noise for this fixture, not a guaranteed hardware noise floor. | +| Synthetic early-note RMS changed by -0.26 to -0.46 dB; late decay by about -7.3 to -7.7 dB | `diagnostic_only`: the gain curve affects compression and quiet decay. No claim of unchanged sustain. | +| Fresh digital silence is exactly zero; post-note silence decays below -198 dBFS | `pass` for the bounded finite/silence contract. Does not exclude pickup/interface noise. | +| Guitar, all three gain pedals, gates disabled: Vintage noise -6.85 to -11.21 dBFS; Sustain -7.65 to -13.06 | `diagnostic_only`: cascaded gain can still produce substantial hiss. The input Gate must be set above the actual idle signal and auditioned for release behavior. | +| 129 paired recorded-guitar/synthetic-bass WAVs for the gain-curve-only iteration | `pass`: 87 files byte-identical, including bypass, Gated and maximum-Drive cases; 42 moderate Vintage/Sustain files changed. This does not certify later Distortion changes or preferred tone. | + +**Stacking exposed two independent numerical defects in Distortion.** Its +float antiderivative quotients could turn a one-ULP input change into roughly +0.0018/0.0012 output jumps. Stable interval evaluation now agrees with an +independent integration oracle within 8.88e-8 in 3,578 cases, including bias +changes, constant/zero input, branch boundaries, knees and adjacent floats. + +A second defect was the Saturator/Distortion 20 Hz high-pass at the oversampled +rate. At 48 kHz x 8, the actual float coefficients gave `1 + a1 + a2 = 0`; +tiny upsampler partition differences of about 1.96e-8 became 0.028 through +that isolated filter. Double coefficient/state processing kept the difference +near 1.95e-8. The correction preserves the cutoff, order and intended response; +there is no extra filter or latency. It also changes standalone Saturator +low-cut numerics, including the archived signal path, so old output is not +promised byte-identical even though stored controls and recall are unchanged. + +The complete production chain then passed the unchanged 1e-5 partition +requirement: maximum difference 1.30e-6 across 32 gain-mask/profile/voice routes, +and 7.16e-7 in the seven localization controls covering standalone Fuzz plus +both internal-Fuzz and identical pre-rendered-Fuzz routes at 2x/4x/8x. Native +oversampling readback was checked. The initial failing captures are retained; +tolerances were not widened. All 64 explicit global-Gate cases also passed. +These are numerical/functional `pass` results; preferred tone and audibility +remain `not_asserted`. The temporary filter trace is replaced by lasting +production-path regression coverage. + +The recorded guitar source is the retained CC0 FreePats fixture; bass is +explicitly synthetic. Full-chain captures use the explicitly supplied local +NAM amp and cabinet IR, whose identities/hashes are in each manifest. Their +third-party licensing was not independently certified; these resources and +the rendered pack are not added to the website or app distribution. No WAV +normalization was used. The final 129-WAV pack has 51 files byte-identical to +the original baseline. Comparing the intermediate gain-curve build with the +final precision correction, all 33 direct Fuzz files remain identical and +exactly 48 full-rig files change: all and only the Distortion-enabled masks. +No final file contains a sample above unity. `audition.html` at the evidence +root presents eight before/current pairs with exact settings; all 16 media +resources loaded in Chromium. This verifies the preview, not an audition. +Pedal-only and full-rig captures are separate, and +metrics do not substitute for audition of those exact artifacts. + +The broader uncommitted-tree review additionally repaired a Reverb response +capture race (flush pending parameter writes before rendering), wide Reverb +footer overlap, stale ASIO acceptance feedback, the default native-suite +preview-result aggregate, and desktop qualification process cleanup. The UI +coverage inventory now includes Find, and its generator sorts portable source +names consistently across Windows/Linux instead of producing reorder-only +diffs. The Debug realtime optimization list now includes PitchDetector/PitchMapper. +Windows/macOS/Linux CI definitions now invoke the realtime executor contract; +local execution on Windows does not establish the other platforms. + +Final frontend qualification passed 2,948 tests across 249 files, focused +NAM unit checks and 58 Chromium cases across seven interaction/editor suites. +The NAM pedal was checked at 640/900/1280/1920/3840 CSS-pixel widths, including +focus, exact entry, reset/Undo and overflow; these are browser fixtures, not +physical mixed-DPI/native-window tests. Python tooling ran 357 tests: 333 passed and 24 platform-specific tests were +skipped. Product naming and UI-inventory checks passed. The default Release +free-suite run passed all 185 checks and offline render/export passed all 264 checks, then +passed again after the Distortion/Saturator precision correction, including +Saturator parallel/dry/partition/reset/recall and transition coverage. Subjective sound remains `not_asserted`. + +Final CMake Debug and Release builds completed without compiler warnings. +Each final focused NAM run passed 33 check groups (three diagnostic-only and +two explicitly unasserted groups are separate), including the new nine-case +production low-cut oracle and 2x/4x/8x stack smoke. The low-cut independent +reference error stayed below 7.88e-12; one-ULP input sensitivity stayed below +6.64e-9. All 146 packaged frontend files match `frontend/dist` in both builds. + +The final isolated Release CPU comparison covers 28 one-rack cases, three +shuffled repeats each: Guitar/Bass, three Distortion modes, 16/64 frames and +4x/8x, plus four Fuzz/Drive/Distortion stacks. Median-of-three average callback +costs were 4.1-15.2% lower than the frozen curve-only build (median 7.7%). +All 84 final rows had zero observed deadline misses in this short capture; +12 earlier rows contained misses. These are `diagnostic_only` machine-specific +measurements with loaded but bypassed amp/cab models, not four-track host or +physical-driver qualification. Eight Debug processing-thread heap probes, +including warmup, recorded zero allocations and frees. Exact manifests, +resource hashes, frame counts and comparisons are retained in +`cpu-final-comparison.json`, `cpu-final/` and `heap-final/` under the evidence +root. Do not extrapolate a device-safe buffer setting from these stage tests. + +The sibling website adds the dated article *Tuning OpenStudio's free effects: +gain, space and the audio deadline*, with reference links and measured limits. +Its build, strict TypeScript and lint passed; focused article/image/SEO tests +passed 11/11 and header/navigation geometry passed 8/8. The full website run +initially passed 233/235, with allocation/browser-launch failures in two OG +image tests; the focused retry passed 5/5 without a code workaround. Actual +article views at 320/1440 px and DPR1/2 passed overflow/image/link checks. +A confirmed narrow-header overlap and inline-Markdown image-discovery gap were +also corrected. No publication was performed. + +Release qualification is still open: exact-artifact user listening, sustained +physical-interface recording, the required four-track/16-sample capacity +target, other OS/driver combinations, and the affected Windows 10 Repair +scenario are not cleared by these tests. No candidate version was chosen and +no release-notes, packaging or publication gate was bypassed. The website +article describes development work explicitly; it does not advertise these +changes as released. + +### NAM audit, October 22, 2026 + +This is the **pre-change baseline**, not current-source qualification. The +[maintenance contract](nam-rack.md#cpu-and-pedal-interaction-contract) owns +implementation semantics; the following section records the subsequent repairs. +Retained evidence root: `output/review/nam-rack-audit-20261022/`. + +| Baseline | Finding and retained evidence | +| --- | --- | +| Two-Rack CPU | Three Release and one Debug run, 1,024 callbacks per case on Ryzen 9 7900 X. All 60 integrity cases and optimization contracts passed per run, with no hardware opened. Capacity failed in 43/44/41 Release cases and 51 Debug cases. See `cpu-release-summary.json`, `cpu-release-provenance.json`, `cpu-debug-provenance.json` and their four timestamped reports. | +| Complete chains | At 44.1 kHz/8 samples, complete mono means were 210.0–214.4 us without Fuzz and 209.6–214.9 us with it, against 181.4 us; both missed all 1,024 deadlines. Complete stereo also missed every 8–64-sample deadline at both rates, and nearly/all deadlines at 128. Bundled heavy fixtures, fixed ordering and signal-dependent work prevent attributing a Fuzz speedup or the user's constant crackle. Coherent summed peak 1.997379 is a separate headroom diagnostic. | +| Fuzz structural/audio exports | `fuzz-release-summary.json` and `fuzz-release/fuzz-44690b14/manifest.json`:16 passes,5 diagnostics,1 unasserted;33 pedal exports,96 full-chain renders and 144 boundary cases. Full-chain peak 0.297531843 with zero guard hits. The Fender Twin/Mesa V 30 fixture fixes Octave on/Clean 0, omits Crunch and surrounding effects, and uses arranged guitar/synthetic bass. Boundary pre-Amp peaks exceed unity in 52/144 cases (max 3.891745); voice RMS spread 14.283710 dB is diagnostic, not tone approval. | +| Octaver retirement | The isolated actual-source probe's `octaver-probe/results.json`, source/executable hashes and execution record retain 41 failed exact partition comparisons out of 73. Both wet voices 12/24 (max 0.168061169), Down 14/24 (0.012105137), Up 14/24 (0.008062690), overlapping profile 1/1 (0.001221936). Pre-reentry audio and metadata stayed exact. At 48 kHz, fade at 4096 plus 959 ramp samples and 19 fully-off samples first differs at 5074: callback-dependent retirement state, not a ramp-law failure. This does not explain crackle with Octave disabled. | +| Actual saved presets | `preset-audit.json` retains Bestest and both bass alternatives plus five model/IR hashes. All saved states have Octave/Fuzz off; Bestest already enables Drive. Models are 48 kHz, while saved device state was 44.1 kHz/8/2 x and the user reported 4 x. Neither saved settings nor labels establish historical live state. One version 2 Bass-labelled payload canonically restores Guitar; never infer a migration override from its name. No preset was modified during the audit. | + +Measured executable SHA-256: +Release `cd32fe8ed3ef9329fbd8c8ad21b5da7935ca235efbdfa486da3116c0df0bc52e`; +Debug `503d2c81c0a27d218b0d9a2d9031f158c34fcdf1b8059b797c4690f6c57b5a71`. +Exact runs are `20261022_190141_cpu-release-1`, +`20261022_190248_cpu-release-2`, `20261022_190355_cpu-release-3` and +`20261022_191331_cpu-debug`, each containing +`nam_rack_regression_result.json`. Full Fuzz results are in +`fuzz-release/20261022_190854_fuzz-interaction/nam_rack_regression_result.json`. +These wall-time measurements exclude the live host/device/UI; timing is +`diagnostic_only`, measured capacity is `fail`, and audible severity is +`not_asserted`. + +### NAM implementation qualification, October 22, 2026 + +The follow-up work uses `output/review/nam-implementation-20261022/` for +native evidence and `output/review/nam-rack-implementation-20261022/ui/` for +browser screenshots/results. These are local ignored artifacts, not shipped +fixtures or proof of release qualification. The audit above records the +pre-change baseline; its failures are not silently replaced by newer passes. + +The initial candidate passes the resampler, inactive-delay and new +Drive/Distortion coefficient-cache reference contracts. The cache comparison +uses 48 combinations of instrument profile, channel routing, oversampling and +sequential reprepare rates; control automation, retired internal OD808, +bypass/edit/reactivation and reset are included. Cached and forcibly recomputed +processing must produce exactly equal samples, while coefficient-design counts +decrease. This proves transparent output for those cases, not a CPU percentage. +All **129 default Fuzz WAVs** (33 pedal and 96 amp/cab renders) match the frozen +pre-change executable byte for byte: `default-fuzz-parity.json`. + +Final Release and Debug builds completed with zero compiler warnings. Identities +are in `final-native-provenance.json`. The strengthened cache reference has +actual mono/stereo buses and 12 combined lifecycle events plus 19 isolated edits +per row. All 48 rows pass in both configurations. Permanent Octaver tests pass +3,024 retirement, 168 profile and 216 Octaver-to-Fuzz comparisons, all exactly; +72 frozen unaffected old-code comparisons remain exact. Native profile-copy +regressions pass all 14 filesystem/migration checks without changing user presets. + +The extended interaction manifest is +`fuzz-extended-release/fuzz-482c538b/manifest.json`: **531/531** full-chain +integrity cases pass, with zero ordinary output-guard hits and maximum native +sample peak `0.459602177`. There are 384 topology, 36 Clean, 27 calibration, +63 octave-presence and 21 gate cases, distributed equally over arranged guitar +low notes, arranged guitar chords and synthetic B0 bass decay. The original +144 boundary cases and 33 pedal exports also pass. Actual native float WAVs, +input/resource hashes and recall state are retained. The source is not the +user's original performance; all sound preference claims remain `not_asserted`. + +That combined run intentionally remains recorded as failed because the first +alias measurement engaged the final output guard in 48/144 primary cases. +There was no nonfinite output, emergency mute or processing fallback. A corrected +measurement uses fixed **-18 dB post-chain trim**, leaving input/interstage drive +unchanged and still requiring zero guard hits. The separate final Release and +Debug alias reports pass all measurement contracts: 144 primary cases, +36 factor cases, 18 reference comparisons and 36 exact PDC-aligned inactive +factor comparisons per build. Linear commutation, deliberate nonlinear failure, +known fractional delay and gain-error controls pass. Residual magnitudes and +consecutive-period differences remain `diagnostic_only`; no sound-quality +threshold was invented to turn this into a tone pass. + +All **600** repeated CPU/stress runs below pass processing integrity. Every +aggregate capacity result is **fail** because at least one included run misses +an offline deadline. Counts exclude warmup and use full measured callbacks. +The detailed aggregates are `cpu-evidence-summary.json` and the associated +exact-preset, isolated-stage and long/stress analysis files. + +| Matrix | Runs | Measured callbacks | Deadline misses | +| --- | ---: | ---: | ---: | +| Exact presets, Debug | 156 | 187,794 | 271 | +| Exact presets, Release | 156 | 187,794 | 150 | +| Isolated stage settings, Release | 192 | 281,280 | 71 | +| Sustained exact presets, Release | 24 | 111,642 | 132 | +| Sustained exact presets, Debug | 24 | 111,642 | 119 | +| Bounded one/two/four-Rack stress, Release | 48 | 45,128 | 30,588 | + +Short exact/stage cases use 0.25 s warmup and 0.5 s measurement; sustained cases +use 10 s excitation then 2 s measurement; stress uses 0.25 s each. Exact-preset +and stage cases have three shuffled repeats; stress has two. Debug/Release +outputs match across all **528 channel hashes** for matched short and sustained +exact-preset runs. All preset/model/IR identities are consistent. Fuzz is off in +these exact saved-preset cases, so they do not demonstrate Fuzz-caused crackle. + +For the sustained two-track fixtures at 44.1 kHz / 4x, Debug plus Release have +zero misses in 16,548 callbacks at 64 samples and 8,280 at 128. Eight samples +misses 164/132,300 callbacks and sixteen misses 87/66,156. Start a full-app +audition at **128 samples**: the worst measured 64-sample load reached 98.2% +in Release, leaving little margin for excluded host/device/UI work; 128's +worst loads were 44.4% Release and 53.1% Debug. This is a test starting point, +not a universal hardware guarantee. Stress at 192 kHz and four stereo Racks +overloads even 512 samples in the retained eight-times-Drive cases. + +These offline misses do not identify the cause of the reported constant +crackling. The timing loop runs on the headless control thread, without the +live ASIO callback's MMCSS registration, and measures wall time including +preemption. The sustained Release eight-sample pairs average approximately +60.2 and 38.1 microseconds against a 181.4-microsecond block budget; isolated +maxima exceed the budget despite substantially lower typical costs. Scheduling +interference and host work must be measured in the actual callback before +attributing those spikes to NAM or changing its sound. The earlier silent +8-to-16 buffer transaction tests establish setting acceptance only, not loaded +two-track stability. + +The opt-in `--nam-live-callback-probe` tests those missing host/device layers: + +```powershell +build/OpenStudio_artefacts/Debug/OpenStudio.exe --nam-live-callback-probe Audient --guitar-preset "" --bass-preset "" --output-dir "" +``` + +Run with other audio clients closed and supply each recovered bass preset in a +separate invocation. It opens the selected ASIO interface at 44.1 kHz/4x with +8/16/128-sample buffers, each with 10 seconds warmup and 10 seconds measurement. +Physical input pointers are ignored; precomputed independent mono sources feed +the complete AudioEngine callback, and physical outputs remain silent. The +probe verifies enabled stages, both input routes and NAM execution, faults and +MMCSS, then restores the observed inactive-driver rate/buffer and verifies the +saved settings are unchanged. It never saves the supplied presets. + +Per-buffer CSV files preserve sampled callback timing order; all callbacks +contribute to counts, extrema and histogram percentiles. `engineCapacityStatus` +covers the complete engine invocation, including its telemetry tail and worker +completion, but excludes the probe and surrounding JUCE/ASIO wrapper. `deviceCapacityStatus` +reports driver xruns separately; unsupported readback remains `not_asserted`. +Arrival intervals are `diagnostic_only` because driver batching can affect +them. The process has no WebView, and silent synthetic signals cannot certify +live instrument tone or absence of audible crackling. Report success covers +processing integrity and restoration, not a universal capacity guarantee. + +Retained pre-multicore live Debug results are in +`output/review/nam-live-callback-20261022/`, under `bass-clean-debug2` and +`bass-driven-debug`. Each invocation passes all 17 configuration, processing +and restoration checks. Unlike the earlier offline matrix, these measurements +run on the real Audient callback with MMCSS active and include both armed +TrackProcessors and the complete AudioEngine invocation. + +| Buffer | Bestest + Bass Clean deadline misses | Bestest + Bass Driven deadline misses | +| --- | ---: | ---: | +| 8 | 2,795 / 55,125 | 322 / 55,125 | +| 16 | 429 / 27,562 | 125 / 27,562 | +| 128 | 0 / 3,445 | 0 / 3,445 | + +The worst 128-sample engine durations use 53.7% and 62.7% of the block budget. +Both racks execute with their requested mono routes, loaded amps and 4x drive +oversampling; fault/bypass/lock/resize counters remain zero. Eight- and +sixteen-sample engine capacity is **fail** for these runs; 128 is **pass within +this bounded scope**. Audient returns -1 for driver xruns, so device capacity +is **not_asserted**. JUCE's separate manager count includes host overruns and +must not be described as an Audient dropout counter. + +Synthetic master peaks reach +3.23 dBFS with Bass Clean and +1.63 dBFS with +Bass Driven, even though physical outputs are muted. This is separate summed +headroom evidence: use post-FX track/master gain to keep actual output below +full scale; a larger buffer cannot correct excess output level. Live instrument +clipping and subjective audio remain **not_asserted**. The initial probe built +CSV text before collecting rolling stage telemetry, allowing its eight-sample +window to expire; raw timing traces and cumulative counters remain valid. The +probe now captures snapshots before report generation. Available 16/128 +samples identify the broad track/FX/routing stage as dominant, without +isolating an individual effect or proving a scheduling cause. + +The Release repeats (`bass-clean-release`, `bass-driven-release`) also pass +all 17 integrity/restoration checks each. For the same measured callback +counts, Bass Clean records 1,415/160/0 deadline misses at 8/16/128 samples; +Bass Driven records 47/79/0. Their maximum 128-sample engine loads are 47.4% +and 30.6%. Across both builds and presets, 128 samples has zero misses in +13,780 measured callbacks; this remains bounded evidence with no visible UI +or physical instrument signal. Release improves typical processing cost but +does not qualify 8/16 samples. Corrected Release snapshots retain the +eight-sample stage window and again identify track/FX/routing as dominant. + +The preview correction passes `instrument-preview`, `ir-audition` and +`preview-callback-bounds` in Debug and Release. These cover lease publication, +preparation, release/stop and reopening; exact compact-buffer scrub parity; +poisoned scratch/output suffixes; zero/over-capacity bounds; and actual offline +AudioEngine callbacks at 5/8/16/127/512 samples against a fake 512-sample device. +CPU savings from this small correction and audible improvement are not asserted. +Both native builds complete without compiler warnings. `hardware-tested-native.json` +records tested executable hashes; the final Debug refresh additionally includes +the reporting-order correction, which does not change production DSP. + +At 44.1 kHz / 8 samples / 4x, independent median stage differences suggest +Fuzz adds approximately 0.95-1.41 microseconds, Octaver 3.4-3.9, Drive 1.5-2.1, +Distortion 9.6-10.6 and the bass Reverb/PAD texture about 8.75. These are +`diagnostic_only` differences between separately repeated runs, not sampled +CPU profiles. The stage baseline still processes the loaded Amp while its +power is off: the Rack keeps neural history warm before the power blend. +Consequently Amp-on minus that baseline does **not** measure model cost. +Room/Doubler fixtures include Cab and must be compared against Cab alone. +Suspending neural/Fuzz voices, sharing stereo histories or decimating safety +checks is deferred until a defined re-entry contract and measured benefit exist. + +Long synthetic renders reach unattenuated two-track sums of 1.291 (+2.22 dBFS) +with Bass Clean and 1.190 (+1.51 dBFS) with Bass Driven. Bestest and Bass Driven +also engage the per-Rack ordinary output guard in some cases; no emergency mute +occurs. These pre-mixer sums are a separate headroom diagnostic, not proof of +master/device clipping or the reported crackle. Check actual track/master +levels during audition; no preset gain was silently normalized. + +The four initial and 24 extended Debug CRT heap scenarios observe zero +allocations/frees inside processing, including warmup, irregular callbacks, +Fuzz voices/Clean, one/two/four Racks and mono/stereo stress. Eight malformed +CPU manifests are rejected before any scenario processing. These bounded tests +do not prove absence of every allocator, blocking operation or possible runtime +transition. + +Use the existing focused runner for opt-in complete preset scenarios: + +```powershell +tools/run-nam-rack-headless-regression.ps1 -SkipBuild -AppPath ` + -CpuScenarios -OutputRoot +``` + +The manifest has `schemaVersion: 1`, `warmupSeconds`, `measurementSeconds`, +`repeats`, and `cases`. Each case names an `id`, `sampleRate`, `bufferPattern`, +`oversamplingFactor`, `routedInputChannels` and one to four `racks`; each Rack +specifies an absolute `presetPath`, synthetic `input` (`guitar`, `bass` or +`silence`) and optional numeric parameter `overrides`. Optional +`probeCallbackHeap: true` instruments Debug CRT allocation/free activity on +the processing thread; it does not cover every allocator or other threads. +`exportDirectory` writes unnormalized float WAVs to a new directory. Presets +are migrated in memory; original files and active app state are not changed. +Resource hashes, canonical state, readback and actual NAM execution are retained. + +Cases use fixed audio duration, full measured callbacks and deterministic +shuffled repeats. `success`/`objectiveGateStatus` describe processing integrity; +`capacityStatus` separately fails if any measured callback misses its offline +deadline. Add `-RequireCapacity` when that is the intended gate. Means, +p99/p99.9/max, deadline fractions and misses are diagnostic timings excluding +device scheduling, the host graph, editors and the master chain. + +The focused Fuzz runner accepts `OPENSTUDIO_NAM_FUZZ_EXTENDED=1` to extend +full-chain topology/context coverage, and `OPENSTUDIO_NAM_FUZZ_ALIAS=1` for +aligned Fuzz/Drive/Distortion complex-residual measurements. The latter's +linear, deliberately nonlinear and fractional-delay controls qualify the +measurement; residual magnitudes remain `diagnostic_only`, including filter, +ADAA and envelope differences. Neither flag establishes an audible tone pass. + +With other audio clients closed, the explicit hardware diagnostic is: + +```powershell +OpenStudio.exe --audio-buffer-probe Audient --output-dir +``` + +It now requests 8, 16, 64, then 8 samples through the app's device transaction, checks +actual callback sizes, restores the initially observed rate/preferred buffer, +and verifies saved settings bytes are unchanged. Physical input pointers are +never read or recorded; output samples are cleared. This tests device-setting +acceptance, not two-track NAM capacity, guitar quality or audible crackling. + +The earlier October 22 Release hardware run used the original 8/16/8 sequence +and passed all ten checks on **Audient USB Audio ASIO +Driver at 44.1 kHz**: 4,145 callbacks at 8 samples, 2,067 at 16 and 4,195 at 8; +each step's minimum and maximum callback size matched its request. The original +44.1 kHz / 8-sample configuration was restored and saved settings remained +byte-identical (`asio-buffer-transaction/result.json`). Driver xrun reporting +returned `-1` (unavailable), so this is not an underrun-count assertion. A +concurrent offline render was running; no other audio client or project was +opened. The browser Settings tests independently cover preserved draft choices, +late/stale responses, rejection, coercion feedback and keeping OK open when the +accepted buffer/rate differs. Those earlier tests did not exercise the returned +native discovery JSON and did not establish the cause of the reset to 8. +The October 22 Debug executable independently passed the same ten hardware checks +with restored driver/settings state (`asio-buffer-transaction-debug/result.json`). +Neither run processes NAM on the physical callback or records input audio. + +The October 23 follow-up identified a same-device discovery publication bug: +the linked JUCE 9 `NamedValueSet::set` ignores an object replacement when its +contents compare equal. The query assigned an equal clone before changing its +rate/buffer, so the returned object's `current` could remain at 8 while the local +draft and log said 64. The correction publishes the fully resolved draft at the +end, including failure paths. Regression coverage calls the real engine query +and inspects its returned JSON for 16/32/64/128-sample edits, sample-rate changes, +and discovery failure, while verifying that the active snapshot remains intact. +The frontend mocks had constructed the desired draft directly, so their passing +dropdown tests could not catch this native serialization boundary. + +The same follow-up adds explicit **Active audio** and **Pending** values to +Settings. The 32 focused frontend tests, 10 existing Chromium Settings scenarios, +and frontend build pass; Vite still reports its existing large-chunk/dynamic-import +warnings. A separate Edge browser fixture verifies visible 64 pending while +Active audio remains 8, Apply changing Active audio to 64, and Cancel discarding +128 so reopening still shows 64 (`output/playwright/audio-buffer-active64.png`). +This is a browser fixture, not a complete desktop WebView/driver integration run. +Debug and Release builds pass `--realtime-track-self-test`: 12 executor checks, +graph eligibility, and 36 full-engine checks, including ten new query-publication +and probe checks: eight query/publication checks and two probe-oversampling +guards. A reproducer linked to the exact JUCE core confirms returned 8/local 64 +before final publication and returned 64 afterward. + +The extended silent Audient transaction passes all 11 checks at 44.1 kHz: 4,132 +callbacks at 8 samples, 2,064 at 16, 516 at 64 and 4,140 at 8, with exact minimum +and maximum callback sizes at each step. It restores 44.1 kHz/8 and leaves saved +settings byte-identical. Driver xrun reporting remains unavailable (`-1`), and +NAM capacity/audio quality are `not_asserted`. Evidence is retained under +`output/review/audio-buffer-20261023/`, including `contracts-debug.json`, +`contracts-release.json` and `silent-buffer-change/result.json`. + +The subsequent loaded two-track follow-up is summarized in +`output/review/audio-buffer-20261023/hardware-summary.json`, with individual +reports under `debug-2-clean-8x`, `debug-2-driven-8x`, `release-2-clean-8x` and +`release-2-driven-8x`. Both builds completed without compiler warnings. Debug +SHA-256 is `7dfc79de58477cc9b5641d905ac85e8d77f53f82087689c9701390bd112e1392`; +Release is `978d41f9f9a97d59b08abaef0e293145b0c374b901636b27377a7cb1529e3f19`. +Each case runs Bestest clean with guitar Distortion engaged plus one of the two +recovered bass presets, at 44.1 kHz with requested and active **8x** Drive +oversampling verified. Both tracks are armed, monitored and recorded. Automatic +helpers are enabled, with polling and recovery grace both zero. Warmup is three +seconds, followed by ten measured seconds per case. Fuzz and Octave remain off +as saved in these presets; this is not an all-pedals-engaged qualification. + +| Build / bass preset | Buffer | Mean / p99 / max engine time, us | Outer misses | Worker misses / quarantined blocks / cancelled jobs | Engine capacity | +| --- | ---: | ---: | ---: | ---: | --- | +| Debug / clean | 8 | 112.4 / 219.0 / 454.3 | 1,610 | 5,769 / 6,178 / 6 | `fail` | +| Debug / clean | 16 | 180.3 / 352.9 / 633.6 | 172 | 546 / 214 / 0 | `fail` | +| Debug / clean | 64 | 430.5 / 697.3 / 943.9 | 0 | 0 / 0 / 0 | `pass` | +| Debug / driven | 8 | 90.1 / 155.2 / 444.2 | 228 | 462 / 73 / 20 | `fail` | +| Debug / driven | 16 | 134.5 / 211.2 / 521.7 | 12 | 17 / 0 / 0 | `fail` | +| Debug / driven | 64 | 399.5 / 640.6 / 880.2 | 0 | 0 / 0 / 0 | `pass` | +| Release / clean | 16 | 110.1 / 218.3 / 407.6 | 2 | 10 / 9 / 0 | `fail` | +| Release / clean | 64 | 320.0 / 583.9 / 861.7 | 0 | 0 / 0 / 0 | `pass` | +| Release / driven | 16 | 104.3 / 184.2 / 467.4 | 38 | 43 / 0 / 0 | `fail` | +| Release / driven | 64 | 292.4 / 453.5 / 643.1 | 0 | 0 / 0 / 0 | `pass` | + +The nominal budgets are 181.4, 362.8 and 1,451.2 us for 8, 16 and 64 samples. +Outer timing covers 55,125, 27,563 and 6,890 measured callbacks respectively; +worker counters use per-case deltas across warmup and measurement. Rejected jobs +and batches are zero throughout. All four 64-sample cases pass both outer and +worker capacity, including zero quarantine/cancellation. All six smaller-buffer +cases fail. Quarantined callbacks can skip DSP, so their low mean is not a +throughput improvement. These bounded two-track results do not replace the +historical four-/twelve-track 4x failures below or qualify eight-sample use. + +All ten cases pass processing integrity. All four runs restore the observed +driver configuration and saved settings. All **20 raw mono WAVs** pass exact +frame count/rate and every-sample parity within two 16-bit PCM LSBs; there are no +recording write failures. The real Audient ASIO callback processes independent +synthetic inputs; physical inputs are ignored and physical outputs are cleared. +Timing includes the full engine callback but excludes the probe, surrounding +JUCE/ASIO wrapper and WebView. Driver xruns remain unavailable, so device-path +capacity is `not_asserted`. The unchanged preset gains sum above full scale in +every case (64-sample peaks approximately 1.182 for clean bass and 1.350 for +driven bass); this is `diagnostic_only` headroom evidence, separate from timing. +A larger buffer cannot correct excess output gain. Thirty-minute endurance, +visible-app/control interaction, physical guitar/bass routing, round-trip +latency and audible crackle/tone acceptance remain `not_asserted`. + +Frontend evidence records 82 focused unit passes, 20 Settings scenarios across +Chromium/WebKit and seven NAM workflow/geometry browser scenarios, with compact +through 4K/DPR2 screenshots. A subsequent exact-entry clamp/rounding correction +passes all seven tests in its focused unit file; that small follow-up did not +rerun the browser matrix. Production/E2E TypeScript and `npm run build` pass. +Vite retains large-chunk and ineffective-dynamic-import warnings; these are not +reported as a warning-free frontend build. Debug's copied `webui` assets are +current and no Codex-owned server is required or left listening on 5183. + +### Multicore NAM live qualification, October 23, 2026 + +The development executor now runs eligible independent NAM tracks concurrently +within the current device block. Automatic mode uses physical cores minus one +helpers, with the callback participating; neither the engine nor the scheduler +has a four-track cap. The current per-track eligibility rule, serial fallback, +raw recording and late-work ownership contract are defined in the +[NAM guide](nam-rack.md#four-track-live-monitoring-acceptance-target). +This implementation is **not yet a pass** for four recording/monitored Racks +at 16 samples, and it does not close the user's eight-sample crackling report. + +Run the deterministic device-free gate against each build before hardware +timing. This combines executor ownership/cancellation, actual NAM graph +eligibility, and full-engine serial/parallel routing, recording, scratch-bound +and lifecycle checks. It opens no physical audio device or app window: + +```powershell +build/OpenStudio_artefacts/Debug/OpenStudio.exe --realtime-track-self-test "/debug-contracts.json" +build/OpenStudio_artefacts/Release/OpenStudio.exe --realtime-track-self-test "/release-contracts.json" +``` + +The pending-worker fixtures hold an actual batch across callbacks. All queued +NAM owners, including completed lanes, must remain untouched until retirement; +unowned tracks must continue serially without dispatching another batch. The +six-track case adds an unrelated source/send/destination pair and checks its +monitoring sum and recorded pre-FX send mixture while the four NAM lanes retain +their raw recordings. This protects unrelated routing; a newly connected route +from a quarantined source cannot recover that source's unavailable signal. +Conversely, a newly published incoming send into a quarantined destination is +preserved in that destination's raw recording through the callback-owned +current send accumulator, without accessing its pending DSP buffers. + +The qualified Release and final Debug builds completed with zero compiler warnings. Both +`output/review/multicore-20261022/contracts-release-qualified.json` and +`contracts-debug-final.json` report **pass** for all 12 executor checks, the +graph-eligibility suite and all 26 full-engine checks. Coverage includes stopped/playing send, sidechain and +output-route arithmetic in Float32/Hybrid64, retained-owner-only quarantine, +unrelated and newly added incoming-send recording, raw input continuity and +stop/lifecycle ownership. The corrected recording oracle explicitly accounts +for the real six-sample PDC delay rather than weakening sample tolerances. +This also verifies the functional fix for stopped live hardware input +overwriting an incoming-send mixture. Actual-device capacity remains separate. +The current Release and Debug render/export regressions each pass all **264** +checks with zero failures, including the explicit zero-device offline launch +contract. Their reports are +`20261023_005343_final-release-routing/render_export_regression_result.json` +and `20261023_010303_final-debug-routing/render_export_regression_result.json` +under the same evidence directory. Export correctness does not qualify the live deadline. +The final Debug runtime-safety run also passes all **344** checks with zero +failures (`runtime-safety-final/result.json` under that evidence directory). + +These deterministic contracts do not certify real-device deadlines. For a +short, controlled live comparison, close other audio clients and stop builds, +browser capture and CPU-heavy tests. Keep each run in a new output directory: + +```powershell +# Serial baseline: four independent inputs, two copies of each supplied preset. +build/OpenStudio_artefacts/Debug/OpenStudio.exe --nam-live-callback-probe Audient --guitar-preset "" --bass-preset "" --output-dir "" --probe-tracks 4 --probe-rate 44100 --probe-buffers 16 --probe-workers 0 --probe-warmup-seconds 3 --probe-seconds 10 + +# Same workload with automatic helpers and event-based wakeups. +build/OpenStudio_artefacts/Debug/OpenStudio.exe --nam-live-callback-probe Audient --guitar-preset "" --bass-preset "" --output-dir "" --probe-tracks 4 --probe-rate 44100 --probe-buffers 16 --probe-workers -1 --probe-warmup-seconds 3 --probe-seconds 10 + +# Explicit recording check; writes synthetic raw inputs into this evidence directory. +build/OpenStudio_artefacts/Debug/OpenStudio.exe --nam-live-callback-probe Audient --guitar-preset "" --bass-preset "" --output-dir "" --probe-tracks 4 --probe-rate 48000 --probe-buffers 16 --probe-workers -1 --probe-recording --probe-warmup-seconds 3 --probe-seconds 10 +``` + +Repeat with the other recovered bass preset and the Release executable. The +probe alternates independent guitar/bass processor states on separate +synthetic mono channels, even when the physical interface has only two inputs. +Physical input pointers are never read and physical outputs remain silent. +Guitar Distortion is enabled; all other requested stage enables, model/IR +identities, the requested oversampling, input peaks and executed NAM frames are checked. +It restores the inactive driver's observed rate/preferred buffer and verifies +saved settings are byte-identical. It never saves source presets. + +`--probe-tracks` accepts 1-64 for the harness; this is a probe bound, not a DAW +track limit. `--probe-rate` accepts 44100, 48000, 88200, 96000, 176400 or 192000; +the driver must actually accept the request. `--probe-buffers` accepts a comma +list, `--probe-workers 0` selects serial and `-1` selects automatic helpers. +`--probe-oversampling` accepts 2, 4 or 8 and defaults to 4. This applies to the +native Precision Drive and Distortion stages; it does not change NAM model or +Fuzz quality. Record the selected factor with every workload comparison. The +historical final matrices below used 4x throughout. +`--probe-worker-spin-us 500` is an opt-in comparison of bounded active polling; +zero is the default event-based policy. The allowed polling range is 0-2000 +microseconds, and its CPU/power cost must be assessed separately. +`--probe-worker-grace-us` separately permits 0-2000 microseconds of diagnostic +recovery waiting after the nominal worker collection deadline. Its default, +including in the normal app, is zero. This can help distinguish immediate +cancellation/quarantine from slightly late completion; a nominal deadline miss +still increments the failure counter and fails capacity even if the worker +finishes within the grace. It adds no audio buffering and cannot turn a late +16-sample callback into a pass. Record the requested grace with every result. +Measurement duration is 1-1800 seconds, with 0-60 seconds warmup. Use 1/2/4/8/12 tracks as +targeted scaling points, not a full Cartesian sweep before the failing +four-track case is understood. + +For recording, every resulting mono WAV must have the exact processed frame +count and rate, nonzero input, and sample parity with its independent synthetic +source within two PCM least-significant bits (2e-6 for floating-point files). +Warmup and measurement are both recorded. A valid raw file does not prove that +software monitoring avoided dropped work. For longer runs, timing memory is +bounded: all callbacks contribute to counts/histograms, while CSV holds at most +65,536 uniform samples plus the first 4,096 deadline misses. Histogram +percentiles are upper-bin bounds, not sorted exact percentiles. + +Interpret the report fields separately: + +- `processingIntegrity` gates requested state, nonzero finite execution, + callback safety and requested recording checks. Top-level `success` and a + zero exit code describe integrity/restoration, **not capacity**. +- `callbackDurationCapacityStatus` checks the measured outer engine duration. + `workerCapacityStatus` checks per-case deltas across warmup and measurement + for missed deadlines, cancelled/rejected jobs, rejected batches and quarantine. + `engineCapacityStatus` fails for either kind of overload; returning early by + dropping work cannot pass. Worker snapshots are captured before device stop, + preserving actual helper/MMCSS and executed-job witnesses. +- `deviceCapacityStatus` is separate driver xrun readback. Audient's unsupported + `-1` remains `not_asserted`; JUCE's manager count also includes host overruns. + Arrival intervals and synthetic summed clipping remain `diagnostic_only`. +- The proxy times the full engine callback, including its telemetry tail, but + excludes proxy preparation/inspection, JUCE/ASIO wrapper work and a WebView. + Actual instrument routing, audible quality and full-app stability remain + `not_asserted` until tested directly. + +**Qualified Release measurements:** +`output/review/multicore-20261022/release-final-matrix-summary.json` summarizes +nine invocations and ten buffer cases on the inspected Ryzen 9 7900X/Audient +system. Every result identifies the same executable SHA-256, +`b100fdc94bd9868752750083dfe909dfafd2b2ce64ff2d41b318ac420de1ec1d`. +All configuration, processing-integrity, requested recording and +driver/settings-restoration checks **pass**. All ten strict engine-capacity +cases **fail**. Driver xrun readback is unsupported (`-1`) in every case and +remains **not_asserted**. + +Each case uses 4x Drive oversampling and three seconds warmup. Non-recording +cases measure ten seconds; recording cases measure fifteen seconds. Bestest +with Distortion enabled alternates with Bass Clean unless Driven is named. +Automatic mode starts 11 MMCSS helpers; four-track batches use at most three, +while twelve-track batches can use all eleven. The default event-based policy +and zero recovery grace apply except in the two explicit diagnostic comparisons +below. Sixteen samples provide 333.3 us at 48 kHz or 362.8 us at 44.1 kHz; +eight samples at 44.1 kHz provide 181.4 us. + +| Workload | Mean / p99 / max engine time (us) | Outer misses / measured callbacks | Worker misses / quarantined blocks | +| --- | --- | --- | --- | +| 4 tracks, 48 kHz / 16, serial | 338.2 / 550.8 / 1,006.3 | 6,106 / 30,000 | 0 / 0 | +| 4 tracks, 48 kHz / 16, automatic | 169.6 / 291.7 / 377.2 | 12 / 30,000 | 171 / 106 | +| 12 tracks, 48 kHz / 16, automatic | 186.6 / 313.8 / 483.9 | 28 / 30,000 | 387 / 355 | +| 12 tracks, 48 kHz / 16, 5 helpers | 227.1 / 424.5 / 659.3 | 2,656 / 30,000 | 11,264 / 11,736 | +| 4 recording tracks, Clean, 44.1 kHz / 16 | 189.1 / 311.8 / 418.3 | 11 / 41,344 | 93 / 52 | +| 4 recording tracks, Clean, 48 kHz / 16 | 168.8 / 289.1 / 456.3 | 25 / 45,000 | 206 / 125 | +| 4 recording tracks, Clean, 44.1 kHz / 8 | 123.6 / 171.5 / 413.8 | 503 / 82,687 | 5,807 / 6,010 | +| 4 recording tracks, Driven, 48 kHz / 16 | 129.1 / 204.4 / 386.5 | 4 / 45,000 | 12 / 4 | + +Outer counts cover measurement only; worker/quarantine counts are per-case +deltas across warmup and measurement. These overlapping counters are not +additive. Callback averages include overload containment, so lower timings +cannot be interpreted as equivalent complete-work throughput when lanes were +skipped. The five-helper twelve-track case additionally cancelled 87 jobs; +the eight-sample recording case cancelled two. Scaling beyond four tracks is +demonstrated, but neither a lower helper count nor a low p99 met capacity. + +The optional four-track/48 kHz/16-sample polling comparison +(`release-4-48000-spin500`) averages 160.0 us, with 13 outer misses, 92 worker +misses and 59 quarantined blocks. The 1,000 us recovery-grace comparison +(`release-4-48000-grace1000`) completes all 173 late batches without quarantine, +but records 99 outer misses and still fails its nominal worker deadline gate. +Neither experiment qualifies 16 samples or changes the app defaults: polling +and recovery grace remain off. + +All four recording cases produce four valid files each: **16 mono PCM files +pass** exact frame-count/rate checks and full raw-input parity within two +16-bit LSBs. The 44.1 kHz/16 and /8 cases contain 793,808 and 793,800 frames per +file respectively; both 48 kHz cases contain 864,000. These include warmup. +No write failures are reported, despite monitoring capacity failures. Synthetic +master peaks exceed full scale in every case; summed headroom still needs +separate gain adjustment and is not evidence of physical output clipping, +because the probe keeps the device output silent. + +**Final Debug measurements:** +`output/review/multicore-20261022/debug-final-matrix-summary.json` covers five +invocations and six buffer cases using executable SHA-256 +`c69bf47111c1517ed140c6be47519da300a56d8dbf566e549fcdf6426bf4209b`. +Every case records all its armed tracks, uses 4x Drive oversampling, and passes +configuration, processing-integrity, recording and restoration checks. All six +strict engine-capacity cases **fail**; driver xrun readback remains +**not_asserted**. These are the Debug path used by `python build.py dev --run`, +without the visible UI or physical instrument signals. + +| Recording workload | Mean / p99 / max engine time (us) | Outer misses / measured callbacks | Worker misses / quarantined blocks | +| --- | --- | --- | --- | +| 4 tracks, Clean, 48 kHz / 16, serial | 481.1 / 756.5 / 1,161.7 | 30,000 / 30,000 | 0 / 0 | +| 4 tracks, Clean, 48 kHz / 16, automatic | 205.9 / 324.2 / 554.5 | 147 / 45,000 | 1,081 / 728 | +| 4 tracks, Clean, 44.1 kHz / 16, automatic | 215.2 / 352.9 / 572.9 | 83 / 41,344 | 600 / 492 | +| 4 tracks, Clean, 48 kHz / 8, automatic | 84.5 / 209.0 / 386.5 | 11,288 / 90,000 | 45,338 / 54,767 | +| 12 tracks, Clean, 48 kHz / 16, automatic | 250.7 / 368.5 / 702.9 | 714 / 30,000 | 3,097 / 1,976 | +| 4 tracks, Driven, 48 kHz / 16, automatic | 192.5 / 286.5 / 435.9 | 10 / 45,000 | 120 / 84 | + +All Debug runs use three seconds warmup and the default event-based policy +with zero recovery grace. The serial and twelve-track cases measure ten +seconds; the other cases measure fifteen. Worker columns again use **per-case +deltas**, including warmup, rather than lifetime totals carried over from a +previous buffer. In particular, the Debug eight-sample row is 45,338 worker +misses and 54,767 quarantined blocks; the corresponding Release 44.1 kHz row +above is 5,807 and 6,010. The Debug eight-sample mean of 84.5 us reflects +extensive skipped DSP during quarantine, not a successful speedup or usable +monitoring result; its 48 kHz device period is only 166.7 us. + +All **32 Debug recording files pass**, with zero reported write failures and +full 16-bit PCM parity within two LSBs. Serial and twelve-track files contain +624,000 frames each; 44.1 kHz files contain 793,808; the other 48 kHz files +contain 864,000. Together with the Release cases, **48 files** pass recording +checks while monitoring capacity still fails. These bounded 13/18-second +recordings, including warmup, do not replace the required sustained test. + +Earlier Debug experiments remain historical evidence: initial four-track +44.1 kHz means were 441.7 us serial and approximately 207 us parallel; the +earlier 48 kHz polling run averaged 191.6 us but still missed deadlines. +Those binaries predate the qualified mixed-graph/send-recording fixes and must +not be relabelled as current Release or mixed-graph capacity results. + +The required follow-up is zero callback and worker failures, intact recording, +and controlled latency for at least 30 minutes per required four-track/16-sample +configuration in Debug and Release, followed by visible-app, physical-input +and exact-artifact listening checks. Treat eight samples separately. A low +average or p99 cannot waive a missed deadline; clipping needs gain correction +independently of scheduling. The NAM/Settings warnings now surface recent +executor failures even if the outer callback meter is low. Focused frontend +coverage is `audioPerformanceAdvisory.test.ts` plus `audioDeadlineStatus.test.ts`; +all 22 focused cases pass, keeping device xruns, callback overruns and worker +failures distinct. These UI-state tests do not assert real audio capacity. + +A future, unimplemented diagnostic could choose one logical processor per +physical core using Microsoft's +[topology query](https://learn.microsoft.com/en-us/windows/desktop/api/sysinfoapi/nf-sysinfoapi-getlogicalprocessorinformationex) +and apply optional worker-only +[ideal-processor hints](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-setthreadidealprocessorex), +with unchanged affinity and callback placement. These are advisory preferences, +not reserved cores or proof of SMT separation; first repeat the current +four-track benchmark before introducing another scheduling variable. + +## Input and NAM frontend checks + +Focused unit suites include `singleKeyTransportShortcuts`, `pianoRollNoteDrag`, +`timelineRulerDrag`, `timelineGroupCopy`, `trackCopyPlanning`, +`atomicShortcutMutations` and `namOctaverFuzz`, alongside shortcut/profile, +preset/Compare and gesture tests. Cover held/repeated Record, text-entry +protection, absolute-zero seek, effective scopes/overrides, exact MIDI axis +constraints, selected-group offsets, atomic Undo/Redo and old-state fuzz-off +defaults. Browser coverage must exercise the real App's +`.workspace-sticky-ruler`; the optional internal Timeline ruler is a different +surface and cannot establish the visible ruler's behavior. + +Browser checks use trusted pointer/keyboard events with production components +and store, checking ruler directions/axis locking/cancellation, audio/MIDI copy +previews, track-copy placement, pedal geometry, focus and accessible controls. +Native bridge I/O in browser fixtures is mocked; those results are not audio or +embedded-WebView boot evidence. From `frontend`, run: + +```powershell +npm run test:e2e:types +npm run test:e2e -- timeline-controls.spec.ts editor-gesture-regressions.spec.ts +``` + +The Playwright configuration starts its own Vite server. These migrated suites +use typed helpers in `frontend/e2e/support/`; no standalone browser script or +pre-running server is needed. Public behavior and profile-dependent ruler actions are described in +[input profiles](input-profiles.md#mouse-and-wheel-safety). + +Recording zoom coverage is in `recordingWaveformPreview.test.ts` and +`e2e/recording-zoom.spec.ts`. The browser fixture seeds two takes at 556 seconds, +holds the serial preview response, and exercises the visible ruler in both +vertical directions, horizontal range selection, scrolling, automatic-follow +preferences and Stop. Stationary held drags must retain their anchor from +the initial press, including the pending interval before axis selection, until +mouseup, pointer cancellation or loss of window focus. Capture the baseline +at the actual press and end observation with release; automatic follow +outside the held gesture is permitted and must resume after termination. +Separate synthetic +same-task wheel checks verify zoom-then-pan ordering on the ruler and timeline. +It checks one outstanding peak request, bounded visible +windows, sample-aligned cached waveforms at the current zoom, atomic cursor +anchoring and rejection of stale completions after Stop. Run from `frontend`: + +```powershell +npm run test:e2e -- recording-zoom.spec.ts timeline-controls.spec.ts input-profile-wheel.spec.ts +``` + +The headless runtime safety suite also checks simultaneous preview/status +polling and two-track PCM recording at 8-, 16- and 64-sample blocks. These cases +verify exact file lengths/content, zero polling-induced writer-lock misses, +completed peak boundaries, bounded requests, delayed preview growth and safe +Stop/rollover ownership. Preview maintenance must continue when the timeline is +hidden or polling is suspended. Run with +`tools/run-runtime-safety-regression.ps1 -Configuration Debug -SkipCrashTests`. +These are deterministic `pass`/`fail` contracts, not callback deadline or ASIO +capacity measurements. The original live Audient recording/zoom freeze and +audible continuity remain `not_asserted` until a hardware retest; fixture timing +is `diagnostic_only`. Start/stop writer publication still uses the existing +audio-thread try-lock and is outside the zero-polling-contention guarantee. + ## Click-only metronome shortcut `frontend/src/__tests__/metronomeShortcut.test.ts` checks the dedicated @@ -300,6 +1578,14 @@ The following remain `not_asserted` until the user auditions the exact artifact: ## TONE3000 first-user acceptance +`tools/tone-library-layout-browser-regression.js`, also run by +`frontend/e2e/ui-audit-regressions.spec.ts`, checks compact capture feedback after +keyboard collapse and after collapse/request completion in the same browser +task. Native `
` owns its open state; asynchronous capture feedback must +reveal the panel without a deferred toggle event hiding it again. The same +fixture retains responsive geometry, keyboard access and same-pack reopening +checks. Its catalog and account responses are mocked. + Run this with a clean OS user or after removing only OpenStudio's saved TONE3000 session: @@ -438,6 +1724,483 @@ failure. The runner opens neither an app window nor an audio device. `Source/FreePluginRegression.cpp` registers the current cases; do not keep an obsolete group count as an execution contract. Missing legacy fixtures fail closed. The source-owned `.state` files under `tests/fixtures/free-plugins/legacy/` are compatibility inputs, not disposable generated artifacts. +### Standalone Reverb qualification + +The [maintenance guide](free-plugins.md#standalone-reverb-state-and-processing) +owns implementation and compatibility decisions; the manual owns controls. +The native runner is headless. Use Debug for callback heap instrumentation and +optimized Release for long captures/timing. A selected group is not a full-suite +run, and finite output is not a sound-quality verdict. + +| Cases | Contract or diagnostic scope | +| --- | --- | +| `reverb-output`, `reverb-retirement`, `reverb-factory` | Final gain, bounded tank suspension, complete factory state/response and historical preset identity. Host/export tests additionally cover portable state, disk-preset priority, recall, Mix Lock and mutation guards. | +| `reverb-corrected-controls`, `reverb-return-level`, `reverb-return-ir-crossfade` | All 41 dedicated Wet/Dry ramps, interrupted edits, auxiliary IR outputs, Spatial extremes, append-only compatibility, all fixed wet gains and independent 50 ms kernel-fade reference. Debug measures callback allocations/frees. | +| `reverb-compatibility-sleep`, `reverb-settled-taps`, `reverb-sparse-diffusion`, `reverb-refinement-state` | Masked-engine suspension, Original/Refined reads, feedforward diffusion, four rates, irregular blocks, live changes and old-state migration. | +| `reverb-voice-options`, `reverb-shimmer-scheduling`, `reverb-shimmer-performance` | Old/invalid scheduling/body defaults, Vowel linearity, prior-adapter/reset parity, warmup/cancellation, stale-return exclusion and combined route/pitch/engine/Hold automation. | +| `reverb-extended`, `reverb-extended-heap` | Opt-in maximum finite tails and long Hold/release streams; separate bounded Debug heap probe. The long Release matrix can take about an hour. | +| `reverb-extended-nonlinear-tail`, `reverb-extended-hold-headroom` | Opt-in eight-envelope finite-tail bounds and explicit Infinite/Freeze/Return/Output headroom diagnostics; internal saturation is not instrumented. | +| `reverb-shimmer-instances`, `reverb-instances` | Release-only callback timing diagnostics. Finite output and deadline misses are reported separately; neither proves device dropout behavior. | + +For Shimmer timing, task-local `OPENSTUDIO_SHIMMER_BENCH_RATE` and +`OPENSTUDIO_SHIMMER_BENCH_BLOCK` select rate/buffer; +`OPENSTUDIO_SHIMMER_BENCH_SPREAD_ONLY=1` selects 18 steady Spread cases without +transition/preparation profiling. Each Release case records 2,048 observations +after warmup. Set variables only in the child test process and stop other builds, +renders, browser tests and benchmarks before capture. Aggregate a retained +four-rate/two-buffer matrix with `python tools/analyze_shimmer_performance.py`. + +#### Reproducible response and listening exports + +Generated reports/audio belong under ignored `output/`; reference audio is +never a production dependency. Local recorded fixtures, the user's saved guitar +project and historical before-renders are optional QA prerequisites, not assets +available in a clean checkout. Do not silently substitute another source. + +```powershell +# Original-return impulse responses; use reverb-audit-calibrated for new trims. +powershell -NoProfile -File tools/run-free-plugin-headless-regression.ps1 -AppPath build/OpenStudio_artefacts/Release/OpenStudio.exe -CaseName reverb-audit -ReportPath output/reverb-audit/all-modes-release.json +python tools/analyze_reverb_audit.py --report output/reverb-audit/all-modes-release.json --output-dir output/reverb-audit/analysis +python tools/analyze_reverb_audit.py --self-test + +# Long band-decay and control/route response diagnostics. +powershell -NoProfile -File tools/run-free-plugin-headless-regression.ps1 -AppPath build/OpenStudio_artefacts/Release/OpenStudio.exe -CaseName reverb-correction-responses -ReportPath output/reverb-correction/long-responses.json +python tools/analyze_reverb_corrections.py --report output/reverb-correction/long-responses.json + +# Native mixed levels on three generated materials. +powershell -NoProfile -File tools/run-free-plugin-headless-regression.ps1 -AppPath build/OpenStudio_artefacts/Release/OpenStudio.exe -CaseName reverb-balance -ReportPath output/reverb-balance/native-release.json +python tools/prepare_reverb_balance.py --result output/reverb-balance/native-release.json --output output/reverb-balance + +# All authored voices on three generated and four local recorded materials. +python tools/prepare_reverb_recorded_fixtures.py +powershell -NoProfile -File tools/run-free-plugin-headless-regression.ps1 -AppPath build/OpenStudio_artefacts/Release/OpenStudio.exe -CaseName reverb-voiced-balance -ReportPath output/reverb-voiced-balance/native-release.json +python tools/prepare_reverb_voiced_balance.py --result output/reverb-voiced-balance/native-release.json --output output/reverb-voiced-balance + +# Existing local single-track project through its saved NAM chain and Reverb. +powershell -NoProfile -File tools/run-free-plugin-headless-regression.ps1 -AppPath build/OpenStudio_artefacts/Release/OpenStudio.exe -CaseName reverb-shared-guitar-project -ReportPath output/reverb-shared-guitar-project/native-release.json +python tools/prepare_reverb_shared_guitar_project.py --report output/reverb-shared-guitar-project/native-release.json --output output/reverb-shared-guitar-project + +# Explicitly RMS-matched wet-only character comparison, separate from mix levels. +powershell -NoProfile -File tools/run-free-plugin-headless-regression.ps1 -AppPath build/OpenStudio_artefacts/Release/OpenStudio.exe -CaseName reverb-listening -ReportPath output/reverb-listening/export.json +python tools/prepare_reverb_listening.py --report output/reverb-listening/export.json +``` + +Open each generated `index.html` for comparison. Balance players preserve native +WAV bytes/levels and exclusive playback. The voiced pack has **287 combinations +and 1,722 mixed WAVs**: 41 voices × seven materials × six variants (Wet0, +Original-return100 and Calibrated25/50/75/100), plus source files. Recorded guitar +phrases are arrangements of CC0 DI notes; the short bundled JUCE singing sample +has unverified performer/capture-chain and binary-recording credits and remains +local QA only. Neither establishes broad vocal or live-performance suitability. +The common 0.5 input gain and fixture-preparation gains are explicit provenance, +not output matching. Ten-second captures can truncate long tails. + +The shared-project case requires `test projects/test guitar 1.osproj` and its +referenced recording/NAM model, with the audited single-track routing. Actual +PlaybackEngine/TrackProcessor process saved mono input routing and complete NAM +state; Reverb consumes the same cached native signal before final upstream +latency compensation. NAM's own reverb must be disabled. Source gain is one, +the two-bar quiet lead-in stays, and the fixed ten-second listening tail may +truncate long decays. This does not test the complete AudioEngine/device graph. +The player derives project/source hashes and timing from the native report and +source-timeline capture, without requiring a pre-existing provenance sidecar. It applies one labelled +monitor trim across current/optional `--before-pack` comparisons; WAVs are unchanged. +Wet residuals subtract each voice's own Wet-zero capture because Plate colour +and Positioned room's direct path can remain active there. + +`OPENSTUDIO_REVERB_QA_OUTPUT` overrides the shared-project/voiced capture root; +`OPENSTUDIO_REVERB_QA_PROFILE_OUTPUT=1` retains authored Output trims instead +of diagnostic Output0. Shared-project `OPENSTUDIO_REVERB_QA_TYPE` selects a +single-type pilot (the full comparison requires all 41). Set these only in the +child process. `OPENSTUDIO_REVERB_QA_TRANSITIONS_ONLY=1` limits the long-response +matrix to five Hold/release/route/Spillover cases. Leave it unset for all 95. + +Analysis requires NumPy/SoundFile, with SciPy for bandwise decay and guitar +spectra. `analyze_reverb_audit.py` provides independently checked energy, +arrival, band and pairwise descriptors. `analyze_reverb_corrections.py` fits +Schroeder -5..-25 dB energy decay after causal octave-band filtering; +`analyze_reverb_shared_guitar.py` and `analyze_reverb_voiced_balance.py` add +source-specific descriptors. Short capture quantiles are not RT60. Nonduplicate +hashes, RMS ratios, spectral distances and waveform correlations are +`diagnostic_only`, never perceived-distinctness or audibility gates. +The wet-only character player deliberately uses shared peak-safe whole-file +RMS targets per material; it cannot establish native mix balance or equal +perceived loudness. + +### Other free-plugin signal qualification + +The [Signal path contract](free-plugins.md#corrected-signal-paths) covers the +15 standalone non-Reverb processors. Use `audit-other-baseline`, +`dynamics-audit`, `audit-eq-phase-pitch`, `instrument-audit` and +`audit-modulation` for matrix/behavioral coverage; `audit-other-heap` requires +Windows Debug and instruments first-use and alternate-filter morph callbacks. +The `signal-repairs` group aggregates the six deterministic repair gates and is +included in the ordinary native suite. Windows Release CI also explicitly runs +`signal-repairs`, `reverb-shimmer-scheduling` and `reverb-voice-options`. +Individual repair gates are `signal-compatibility`, `signal-poles-midi`, +`signal-pitch-range`, `signal-transitions`, `signal-drum-release` and +`signal-pitch-fft-parity`. Inspect behavioral rows rather than treating capture +success as a sound-quality pass. `signal-pitch-performance` is a Release timing +diagnostic with explicit overrun counts. + +The native host/export suite additionally checks old numeric snapshots, +full-state/factory recall for all nine Signal path processors and terminal drum +CC123 with automated maximum decay. Preserve frozen Legacy compatibility +fixtures; old failures under `output/free-plugin-repairs/audit-before` are +historical evidence, not the current expected verdict. + +Opt-in `audit-shared-guitar` reuses the previously audited NAM-only source; +it does not capture a fresh AudioEngine/device route. Set child-process +`OPENSTUDIO_SIGNAL_AUDIT_OUTPUT` for another capture root and +`OPENSTUDIO_SIGNAL_AUDIT_LEGACY=1` for Legacy comparisons. +`analyze_free_plugin_audit.py --root --directory +--baseline-report ` checks provenance +and reports active-window guitar/modulation diagnostics. Its baseline defaults +to `/output/reverb-shared-guitar-project/native-release.json`. +`analyze_free_plugin_repairs.py --root ` qualifies complete archived Legacy parity, +intended Piano/Guitar partial frequencies, velocity ordering, partitioning and +state/audio recall. Its optional `--baseline`, `--legacy`, `--corrected` and +`--output` arguments override individual paths; the default repair-pack root is +`output/free-plugin-repairs`. These analyzers require the declared source/archive packs; +missing coverage must fail rather than silently disappear. + +### Design 03 production integration, 22 October 2026 + +The dedicated editors for all sixteen non-NAM processors, including Reverb's +Convolution view, now use the implemented Design 03 layouts. Shared Find opens +the owning control or workflow without changing processing modes; inactive +results explain applicability. Exact entry, fine adjustment, reset, Escape, +paired native gestures, complete-state A/B and Undo remain on the production +bridge/history path. Compact pages and wide overviews share parameter identity. +The manual and maintenance guide retain the feature map and operating contract. + +The local, offline screenshot gallery is +`output/review/fx-design03-production/index.html`. Its 172 captures comprise +seventeen views at nine viewport/DPR profiles plus Chorus at 940x520 and Delay +at 1040x620, plus seventeen extra captures at 624x440 to conservatively allow +for native window chrome. Profiles cover 640x480, 1040x680, 1366x768, 1920x1080, +2560x1440 and 3840x2160, plus 4K equivalents at 125%, 150% and 200% scaling. +These are browser client dimensions; native window bounds include title bars +and frames. Contact sheets and representative full captures were visually +reviewed. All 153 matrix rows have zero measured horizontal-control or +knob-envelope violations. The extra 624x440 set also has no horizontal-control +overflow; dense sections remain scrollable. Detailed pages may scroll; this is not a claim that +every state fits vertically, nor evidence of OS-level or mixed-monitor DPI. + +| Final integration check | Result and scope | +| --- | --- | +| Frontend production build and types | `pass`: `npm run build` and `npm run test:e2e:types`. Dependency notices cover 38 production packages. Vite still reports large chunks and ineffective dynamic imports of already statically imported modules; these are retained packaging/performance follow-ups, not TypeScript errors. | +| Frontend regression suite | `pass`: 2,929 tests in 248 files. The dependency refresh is limited to Vite 8.1.6 and Vitest 4.1.12, with Node types for the browser harness; `npm audit` reports zero vulnerabilities. | +| Production editor browser tests | `pass`: all 23 tests in the three `free-suite-*` specs. They exercise responsive geometry, parameter/history gestures, A/B copy, inactive-value retention, Find including portalled controls, precision tools and instrument navigation. Final edge cases cover disabling captured drags/focused drafts and preserving untouched numeric precision or external updates. The bridge records calls and does not process audio. | +| Reverb page navigation | `pass`: 292 page visits across all 41 machines at 640x480, with no page errors, horizontal-control overflow or parameter writes caused by navigation. This is page/layout coverage, not a full parameter-interaction or audio sweep. | +| Qualification tooling | `pass`: 37 Python tests and 57 subtests; one platform-specific test skipped. The run covers desktop/Linux qualification, Reverb QA tooling and NAM fuzz refinement tooling. | +| Final Debug and Release builds | `pass`: both CMake builds completed with zero compiler warnings. All 146 files in `frontend/dist` match both copied `webui` trees byte for byte; all 437 inspected native source files predate both binaries. | +| Final Release Reverb headroom | `pass`: all 16 bounded continuing-input fixtures, zero final guard hits and exact callback-partition parity. Indefinite accumulation and audible behavior remain `not_asserted`. | +| Final packaged Release lifecycle | `pass`: 99 checks, with all ten frontend documents reaching boot-ready and the Pitch-inclusive geometry contract passing. This normal-mode Windows 11 run proves explicit offline startup, no opened device, zero starts/callbacks and owned-process cleanup. It does not repeat the affected Windows 10 Repair scenario. | + +Final Release SHA-256: +`cd32fe8ed3ef9329fbd8c8ad21b5da7935ca235efbdfa486da3116c0df0bc52e`. +The shared packaged frontend index hash is +`253f2fe44251f42316cdb5e7bd6b1ae5b5b2759ba7cff50f46848aee11b188a3`. +Final native provenance, headroom and lifecycle reports are under +`output/review/fx-design03-final-native-20261022/`. The fresh native editor +schemas match those used by the browser captures exactly. Earlier CPU, +184-check native-suite and 264-check export results retain the distinct measured +binary hashes in the implementation subsections below; do not relabel them as +measurements of this final build. + +**Remaining acceptance:** multi-instance Shimmer and the most demanding Pitch +profiles still fail their strict zero-overrun targets, as quantified below. +Their timing improvements are `diagnostic_only`; hardware dropout behavior, +ASIO 8/16-frame use, commercial equivalence and subjective sound quality remain +`not_asserted`. Real MIDI/analysis/IR jobs are not established by mocked editor +tests. Windows 10 repair, native Linux/macOS and actual mixed-DPI behavior retain +their separate acceptance gates. Build warnings, these measured failures and +the unrun gates remain visible rather than being counted as completed work. + +### Design 03 browser proposal, 22 October 2026 + +The superseded interactive proposal and its exact feature/geometry results remain +under ignored `output/fx-design-review/studio-v3/` (`index.html`, +`gallery.html` and adjacent scripts/results). It covered 136 default captures, +2,505 native descriptors,49 IR fields,174 workflows and 2,378 pure Reverb layout +scenarios. Its simulated controls, zero measured default overflow and fixture +interactions are **not production, native-audio or visual-approval evidence**. +Use the production integration results above and the +[maintenance gates](free-plugins.md#editor-maintenance-and-qualification) +for current editor changes; do not maintain a second prototype parameter state. + +### Design 03 pre-implementation free-suite audit, 22 October 2026 + +This **historical baseline** is retained under +`output/review/fx-design03-dsp-20261022/`: `provenance.json`, +`qualification-summary.json`, `focused-runs.json`, `benchmark-runs.json` +and 143 rows in `timings.csv`. Release SHA-256: +`001ac638e789a2193526604e03b556449f43edf770b660d1655c6a7c2b6fa8fd`; +Debug: `bd45b6cad0503cf3941900a6870c3d92ad7a7096034151cf6fc67a2452133022`. +Subsequent [Reverb](#design-03-reverb-implementation-qualification-22-october-2026) +and [Pitch/Synth](#design-03-pitchsynth-implementation-qualification-22-october-2026) +results supersede only their named contracts, not every device or listening gate. + +The baseline passed 181 ordinary native groups, focused dynamics/EQ/phase/Pitch, +24 Chorus tails, eight instrument variants and state/audio partition checks. +Debug measured zero callback CRT allocations/frees in 30 non-Reverb and 208 +prepared Reverb cases; other allocators, setters and untested transitions were +excluded. Shimmer passed 103 scheduling,40 parity and 73 voice-option cases. +Exact assertion counts and captured WAVs remain in the indexed reports. + +| Baseline capacity/headroom finding | Recorded result; later results are separate | +| --- | --- | +| Continuing-input calibrated Nonlinear Infinite,48 kHz stereo | **Headroom fail:** reaches the +/-4 final guard at 3.677229 s with 1,239,250 guarded sample positions. Freeze and Output-30 dB avoid that final guard; internal saturation/indefinite stability were not asserted. Later 16 bounded headroom fixtures pass. | +| Factory profiles and heavy-mode session | All 32 active/idle factory and 21 heavy profiles had zero observed misses. The eight-processor 48 kHz/128 session p 95/max were 587.4/904.6 us versus 2,666.7 us. This does not qualify all machines or maximum polyphony. | +| Corrected Pitch, four instances,192 kHz | **59/2/1 misses** at 32/64/128 frames; max 248.2/378.5/1,054.3 us against 166.7/333.3/666.7 us. Other tested Corrected profiles had zero; Legacy separately recorded 2,909 misses. | +| Shimmer 48 kHz/128,1/4/8 copies | Spread **0/0/39 misses**, Original **0/753/1,092**. Spread-eight worst p 95/max 2,143.3/3,770.8 us exceeds the 2,666.7 us budget. | +| Spread 192 kHz/64,1/4/8 copies | **0/5,089/10,970 misses**; worst p 95/max 170.5/262.4,853.3/1,249.5 and 1,544.6/2,380.2 us against 333.3 us. | +| Original↔Spread,192 kHz/128,one copy | **7/8 misses**, maxima 7,380.7/7,358.1 us against 666.7 us. Lower steady Spread cost does not qualify switching. | + +Timing used quiet warmed in-process runs on Ryzen 9 7900 X, not hardware callbacks. +`overallPass` covered finite/state contracts; any deadline miss still fails the +workload's strict capacity target. Two old stress labels overstated coverage: +“Room to Shimmer” actually chose Spring (4), and 32 single-channel note-ons used +only 16 Synth slots. The corrected tests select Shimmer (5) and assert 32 rendering +voices across two channels; old rows cannot serve as those benchmarks. +Articulated Drums also intentionally ignores Legacy Kit/Tightness controls; +the editor now explains that while preserving their saved values. + +The baseline render entry opened hardware before its test branch, so host/export +was deliberately not run. The corrected explicit-offline entry subsequently +passed 264 checks with zero device/start/callback witnesses. No baseline claim +covers ASIO 8/16, other CPUs/platforms, exhaustive controls or subjective sound. + +**Effect-law coverage beyond finite extrema remains a maintenance gate:** + +| Family | Meaningful checks retained | Still not established | +| --- | --- | --- | +| EQ/GEQ | Response/slopes, stereo/M-S, phase latency, dynamics/spectral/bank routes, amplitude homogeneity | Wider frequency/Q/gain/automation grids and audible ringing/usefulness | +| Gain Phase | Known delay/allpass/FIR offsets, confidence and declared tails | Real multitrack coherence and audible improvement | +| Compressor/Gate | Clean RMS curves, ratios 1/2/5 at four rates, knee/range/timing, detector listen and partition behavior | Full timing interactions, transient/chatter audition, commercial equivalence | +| Limiter | Independent reconstructed peaks, four rates/styles/oversampling, linking/release and synthetic loudness gates | Broadband/transient breadth, standards vectors and distortion/pumping audition | +| Preamp/Saturator | Unity/bypass, stepped response, headroom/DC/stereo, antiphase/topology transitions | Drive/bias maps, broadband alias/IMD and level-matched listening | +| Reverb/Convolution | IR matrix/state, timing/control/Hold/spillover and retained long responses | Every voice/control interaction, calibrated decay, indefinite accumulation and universal headroom | +| Delay/Chorus | Multi/Dual tap timing, tempo/voices/modes, phaser/ensemble and tails | Continuous feedback/motion/filter maps and audible parameter influence | +| Pitch Correct | Scale/Custom mapping, synthetic detection/response, sustained timing and latency | Voiced/unvoiced transitions, sibilance, attacks and naturalness on approved sources | +| Instruments | Oscillator/MIDI/articulation/pedal/choke, partials, velocity, state/partition and actual Synth 32-voice tests | Wider register/velocity/control grids, all maximum-polyphony envelopes and realism audition | + +Reference behavior stays in [the maintenance guide](free-plugins.md#research-sources-and-decisions). +The manufacturer-linked [Pro-G tutorial](https://www.youtube.com/watch?v=ZtV3s6i-p4Y), +[Pro-L 2 tutorial](https://www.youtube.com/watch?v=oMJeWXtJODc) and +[BigSky Infinite/Freeze demonstration](https://www.youtube.com/watch?v=Qv9QWoHCFhQ) +provide audition context; only available descriptions and manufacturer documents +were inspected, not video audio/transcripts. Infinite must admit new excitation, +unlike Freeze: muting new input to avoid overload changes the function. + +Reproduce with `tools/run-free-plugin-headless-regression.ps1`, selecting the +indexed case/report pair; omit `-CaseName` for the default suite. Timing groups +are `performance`, `performance-stress`, `signal-pitch-performance` and +`reverb-shimmer-instances`, using the documented child-only rate/block/Spread +variables and no competing builds/browser work. `OPENSTUDIO_SIGNAL_AUDIT_OUTPUT` +controls focused WAV exports. Scalar extrema tests never certify the entire +continuous parameter space or an audible commercial reference match. + +### Design 03 Reverb implementation qualification, 22 October 2026 + +The subsequent implementation adds linked smooth write/readout budgets only to +continuing-input Infinite in Nonlinear, dispersive Spring and Rising. Freeze +and finite modes bypass those budgets. Shimmer retains the same spectral +window/hop and return delay: Spread divides each 64-sample group into eight-sample +work quanta while preserving its original ratio and silence decision; inactive +bank clearing is incremental before priming, warmup and crossfade. Clear skips +coefficient rebuilds when its sanitized settings and active state are unchanged. +No parameter IDs, serialized fields or declared latency changed. + +Focused Windows Debug evidence is retained under +`output/review/fx-design03-reverb-implementation-20261022/`. +The second Debug executable SHA-256 is +`8a38059e527846023c0a863332c6bd3ce95d4f591fa66c43a0d0e5dd5da97611`. +Release SHA-256 is +`71b4cb485b4afbed4e891f7994abcd54e6b5ce2041fe7b12c1d4fd5213099a56`. + +| Deterministic contract | Result | +| --- | --- | +| Shimmer signal/lifecycle | `pass`: 44 cases at 44.1/48/96/192 kHz, including exact previous-adapter parity after long silence, exact Original/Spread populated-history reset parity and interrupted route wakeup. Measured callback CRT allocations/frees: zero. | +| Shimmer scheduling | `pass`: 107 state, four-rate mono/stereo, partition, reset, interrupted-transition and dry-unity cases; zero measured callback CRT allocations/frees. | +| Hold input policy | `pass`: 25 cases including Nonlinear at four rates; Infinite accepts new input, Freeze rejects it, and finite release and stored identity remain intact. | +| Bounded Reverb heap/partition | `pass`: 208 prepared family/core/rate/channel Hold/release cases; exact zero partition error and zero callback CRT allocations/frees. Long hold stability is not asserted by this short probe. | +| Clear configuration cache | `pass`: response/partition tests plus unchanged-setting, change, retirement, reactivation and reset invalidation checks in the first Debug build. | +| Continuing-input Infinite headroom | `pass` in Release: all 16 x 73-second regular/irregular Spring/Nonlinear/Rising fixtures, including live Return changes. Zero final safety-clamp hits, measured reservoir writes below their guards, exact partition/state parity, and finite release. Previously failing calibrated Nonlinear Infinite now peaks at 0.75552; worst fixture peak is 0.97177 during a live Nonlinear Return change. Other internal states and indefinite accumulation remain `not_asserted`. | + +The initial eight-sample adapter failed the long-silence parity oracle; its +retained `shimmer-parity-debug.json` is a rejected intermediate result. The +corrected grouped adapter passes with exact zero error in +`shimmer-parity-debug-v2.json`. No tolerance was relaxed. Later timing-harness +changes extend live-switch observations through the actual reset, prime, warmup +and fade plus a settled margin and assert the final target bank is settled; +they do not change production DSP. Subsequent source edits format the new budget +helper without changing arithmetic or processing order, remove unread telemetry, +and update Pitch editor window geometry. The hashes above identify the actual +measured binaries, not the final working-tree executable after those edits. +Hardware dropouts and audible quality remain `not_asserted`. + +Fresh Release timings ran serially on the same Ryzen 9 7900X with browser +capture/test processes and dev servers stopped. Each steady row has 2,048 +observations; there are six routing/input rows per mode and instance count. +The CPU values are `diagnostic_only`; zero-overrun verdicts apply only to these +measured workloads, not hardware-device safety. + +| Workload | Current measured result | +| --- | --- | +| Spread, 48 kHz / 128 frames, 1 / 4 / 8 synchronized copies | Deadline misses **0 / 0 / 11**. Worst p95: 219 / 926 / 1,866 us; worst max: 547 / 1,418 / 3,110 us; budget 2,667 us. Eight-copy zero-overrun target remains `fail`; prior run had 39 misses, p95/max 2,143/3,771 us. | +| Original, 48 kHz / 128 frames, 1 / 4 / 8 copies | Misses **0 / 728 / 1,092**. The compatibility path retains its monolithic FFT work; four/eight-copy zero-overrun targets remain `fail`. | +| Spread, 192 kHz / 64 frames, 1 / 4 / 8 copies | Misses **0 / 5,497 / 11,824**. Worst p95: 149 / 703 / 1,446 us; max: 305 / 1,082 / 2,154 us; budget 333 us. Four/eight-copy targets remain `fail`: worst average cost is 436/916 us, already above budget. Reduced burst concentration does not reduce total work enough and increases the number of over-budget callbacks versus the previous run. | +| Complete live switches, one copy, 48 kHz / 128 frames | `pass` for the observed zero-overrun target in both directions; maxima 1,221/1,570 us versus 2,667 us. All eight rate/instance/direction transition rows reach the asserted settled target bank. | +| Complete live switches, one copy, 192 kHz / 128 frames | **`fail`**: eight misses in each direction; Original-to-Spread max 5,323 us and reverse max 6,853 us against 667 us. Both directions necessarily process the preserved Original bank during some phases. Eight-copy transitions also exceed budget. These longer observation windows supersede the incomplete earlier 230 ms window. | + +The bounded clear removes whole-history clearing from one callback; exact +signal parity and finite warmup/fade are established. This does **not** establish +high-rate live-switch deadline safety or make an over-capacity serial session +fit its deadline. Further reductions require profiling the preserved spectral +work and an explicit compatibility/latency decision before changing Original; +do not silently reduce its window, hop, quality or saved-state identity. +Full rows, preparation diagnostics and executable provenance remain in +`shimmer-48000-128-release.json`, `shimmer-192000-64-release.json`, +`benchmark-runs.json`, and `release-provenance.json` in the same ignored folder. + +Reproduce focused correctness with +`tools/run-free-plugin-headless-regression.ps1 -CaseName -ReportPath `: +`clear-reverb`, `reverb-shimmer-performance`, `reverb-shimmer-scheduling`, +`reverb-hold`, `reverb-extended-heap`, and `reverb-extended-hold-headroom`. +The last profile observes 16 x 73-second regular/irregular headroom fixtures, +including continuing excitation and live Original-to-Calibrated Return changes; +run it in Release for practical turnaround. Timing remains `diagnostic_only`; +use the existing `reverb-shimmer-instances` profile and child-only rate/block/ +Spread variables, with builds and other native/browser workloads paused. + +### Design 03 Pitch/Synth implementation qualification, 22 October 2026 + +The measured local Release evidence uses executable SHA-256 +`71b4cb485b4afbed4e891f7994abcd54e6b5ce2041fe7b12c1d4fd5213099a56` +on an AMD Ryzen 9 7900X. Reports and binary provenance are retained under +`output/review/fx-implementation-20261022/pitch/`. Builds, browser sessions and +other test processes were stopped during the two timing profiles. This is the +measured binary identified above, not a hash of subsequent final-build changes. +Native correctness/export work ran separately afterward; neither is physical-device +qualification. + +Pitch now mirrors its FIR history to avoid inner-loop wrap work, shares input +loads across adjacent YIN lags without changing either accumulation order, +caches unchanged mapper/formant calculations, and splits oversized direct +callbacks into prepared-capacity views. MIDI offsets, latency and bypass +ramps remain intact. Bypass deliberately keeps analysis and Stretch active so +reactivation retains the existing wet history; bypass CPU suspension is not +implemented. Synth caches note frequencies and unchanged bend, pan and envelope +coefficients while retaining per-sample MIDI/MPE smoothing and automation. + +| Evidence | Result and scope | +| --- | --- | +| Detector and mapper arithmetic | `pass`: 12 rate/range detector cases compare against the retained scalar FIR/YIN oracle, with exact zero error at original publication positions and varied callback partitions. Mapper cache transitions also have exact zero error. | +| Pitch direct callback contracts | `pass`: eight Legacy/Corrected, 48/192 kHz, mono/stereo cases. Oversized/prepared output and MIDI offsets agree exactly; bypass emits exact delayed dry after its ramp, resumed wet agrees with the continuously active reference, latency is stable, extra channels clear and unprepared calls return silence. Debug callback probes measured zero allocations/frees. | +| Shared Signalsmith consumer | `pass`: current Release `signal-pitch-fft-parity` after the grouped-silence Reverb repair, plus pitch range, bounded-lag and sustained-Humanize cases. | +| Synth automation and MPE | `pass`: 16 Legacy/Corrected, 48/96 kHz, MIDI/MPE partition cases, with 32 actual rendering voices across two channels. Bend, pan, controller reset, note release and modulated envelope edits agree exactly at matching event positions. Existing destination regressions also pass. | +| Pre-cache Synth audio | `pass`: all 12 persisted default/sine WAVs, including four rates and low/high notes, have identical sample bytes to the earlier audit captures. `synth-wave-parity.json` records both hashes for every file. This is an arithmetic regression check, not a listening judgment. | +| Explicit offline render/export | `pass`: 264 native checks. `hardwareBefore` and `hardwareAfter` both record offline startup, no device, zero starts, callbacks and callback registrations. The headless runner rejects missing/nonzero evidence; ordinary hardware startup is unchanged. | +| Default native free-plugin suite | `pass`: all 184 checks on the same Release binary, including the new Pitch/Synth groups, legacy/state compatibility and the existing effect/instrument/host contracts. This suite does not establish listening quality or a device-safe CPU envelope. | + +Quiet warmed callback timing remains `diagnostic_only`; a missed deadline is +`fail` for the tested workload, regardless of the harness's separate finite +output gate. Each Pitch row has 2,048 observations, while each Synth row has +1,024: + +| Workload | Median / p99 / maximum, microseconds | Deadline, microseconds | Misses | +| --- | ---: | ---: | ---: | +| Corrected Pitch, four instances, 192 kHz / 32 | 18.3 / 169.2 / 226.3 | 166.7 | **25** | +| Corrected Pitch, four instances, 192 kHz / 64 | 49.1 / 233.8 / 289.6 | 333.3 | 0 | +| Corrected Pitch, four instances, 192 kHz / 128 | 110.4 / 301.1 / 374.1 | 666.7 | 0 | +| Synth, 32 voices on two channels, 48 kHz / 128 | 311.2 / 331.8 / 444.1 | 2,666.7 | 0 | +| Synth, 32 voices on two channels, 96 kHz / 64 | 151.8 / 163.4 / 185.5 | 666.7 | 0 | + +All tested Corrected Pitch one-instance rows and four-instance 48/96 kHz rows +had zero misses in this run. The previous four-instance 192 kHz audit recorded +59/2/1 misses; the new 25/0/0 result does not establish a device-safe envelope. +Legacy remains selectable and separately misses deadlines. The stress fixture +now verifies actual voice counts; its earlier single-channel 32-note label +only exercised 16 Synth slots and is not a comparable 32-voice benchmark. +The corrected Room-to-Shimmer fixture selects Shimmer (5), rather than Spring +(4), and reports eight misses at 96 kHz/64. Do not retain the old mislabeled +case as evidence that this switch is safe. + +Reproduce with `tools/run-free-plugin-headless-regression.ps1`, supplying the +explicit Release `-AppPath`, `-CaseName` and a separate `-ReportPath`. Focused +groups are `pitch-detector-parity`, `pitch-realtime-contracts`, +`synth-cached-work`, `pitch-cost-parity`, `signal-pitch-range`, +`signal-pitch-fft-parity`, `humanize-sustained` and `synth-destinations`. +Use `signal-pitch-performance` and `performance-stress` only with competing +workloads stopped. `instrument-audit` writes captures below the child process's +`OPENSTUDIO_SIGNAL_AUDIT_OUTPUT` root. Run +`tools/run-render-export-headless-regression.ps1 -Configuration Release -SkipBuild` +against the same verified binary for offline host integration. Audio quality, +ASIO crackling/dropouts and the user's exact interface settings remain +`not_asserted` until those paths and artifacts are auditioned. + +### Retained audio qualification and limits + +The following is **dated local evidence**, retained from October 11–12, 2026, +not a claim that the current working tree was benchmarked again. Timing is +`diagnostic_only`; measured deadline violations are `fail` for their tested +workload. Listening, physical-device/dropout behavior, other platforms and +exhaustive malformed-state/automation combinations remain `not_asserted`. + +| Evidence | Result and scope | +| --- | --- | +| Corrected Reverb, October 12 | `pass`: 95 long/control responses, exact 48 kHz Legacy/Corrected static parity for eight types, 252 host/render checks and 287 six-variant material combinations. Reference guitar mixes retained about 3 dB sample-peak headroom with authored trims. Quieter material breadth does not qualify arbitrary-input headroom. | +| Shimmer scheduling, October 12 | `pass`: 40 focused voice/lifecycle cases, 103 Release / 107 Debug scheduling cases, zero measured Debug callback allocations/frees and all 41 archived Legacy impulse hashes. `fail`: strict byte equality of four Spread wet guitar files; Wet100 maximum sample difference 0.0000119805, relative RMS difference -110.87 dB (`diagnostic_only`, audibility `not_asserted`). | +| Infinite Hold, October 11 | Finite output, partition/state and release passed. **12 continuing-input Infinite rows reached the ±4 final clamp** (eight Nonlinear, one Spring, three Rising-space); none of 41 finite-tail rows did. The focused 48 kHz stereo Calibrated/Infinite maximum-Nonlinear case first clamped at 3.6772 s with Wet100/Dry0/Output0. Output -30 dB avoided that final clamp in this fixture; internal saturation was uninstrumented. This is not indefinitely unclipped Hold qualification. | +| Pitch Correct, October 12 | 2,048 warmed callbacks/profile. Tested 48 kHz one/four-instance profiles at 32/64/128 frames had zero misses. **96 kHz/32, four instances: 5 misses. 192 kHz/32 or64, one instance: one each; four instances: 57/10.** Both instance counts at 192 kHz/128 had zero in that run. The strict zero-overrun criterion fails for the listed workloads. | + +The Hold source repeated every second at about 0.12 peak. Maximum Nonlinear +used 2 s Duration, 0.95 ordinary Feedback, 20 s Late decay and full Late level; +Infinite circulates at unity while admitting new excitation. Calibrated +Nonlinear adds 30 dB at wet readout, outside feedback. Reducing Output can +avoid the final clamp without proving that internal history stayed unsaturated. +The 41-voice corrected guitar comparison likewise does not establish universal +wet dominance: at Wet100/Dry70, 29 voices had wet RMS at least dry RMS and 12 +did not. Those source-specific ratios are diagnostic, not perceived loudness. + +The archived October 12 Shimmer performance capture used optimized Release on an AMD Ryzen 9 +7900X with 2,048 observations per steady case and no concurrent builds/renders/ +browsers. Its **144 Spread cases** cover Tank/Input/Both, 1/4/8 instances, +identical/different inputs, four rates and 64/128 frames; **49 cases contain +deadline misses**. The table aggregates three routes and both inputs +(**12,288 observations per cell**): + +| Rate / frames | One instance misses | Four instances misses | Eight instances misses | +| --- | ---: | ---: | ---: | +| 44.1 kHz / 64 | 0 | 0 | 0 | +| 44.1 kHz / 128 | 0 | 0 | 6 | +| 48 kHz / 64 | 0 | 0 | 27 | +| 48 kHz / 128 | 0 | 0 | 8 | +| 96 kHz / 64 | 0 | 54 | 4,207 | +| 96 kHz / 128 | 0 | 2 | 4,149 | +| 192 kHz / 64 | 10 | 5,043 | 11,081 | +| 192 kHz / 128 | 2 | 4,497 | 12,271 | + +All single-instance Tank/Input rows had zero observed misses; the single-instance +192 kHz misses were Both. At 48 kHz/128 with eight identical-input instances, +Input had zero misses while Both had four (budget 2.667 ms). At **192 kHz/128, +one instance**, live Original↔Spread switches still recorded **7/8 misses**, +with maxima **5.077/6.984 ms** against a **0.667 ms** budget. These final results +supersede the earlier correction benchmark's Input-eight-instance 31 misses; +do not present the old row as current. Hardware scheduling and isolated DSP +outliers were not separated, and no device-safe envelope is asserted. + +Retained evidence roots are `output/reverb-correction`, +`output/reverb-shimmer-performance` (`timing-summary.json`, +`instances-release-v1.json` and `guitar-comparison.json`), +`output/reverb-completion` (`reverb-extended-release.json` and +`reverb-hold-headroom-release-final.json`) and `output/free-plugin-repairs` +(`final-signal-pitch-performance.json`, `waveform-qualification.json` and +`host-export`). These ignored artifacts may be unavailable to another checkout. +An audible issue needs exact-artifact audition; metrics alone do not close it. + Cover factory/legacy/non-default state, block/rate/channel variation, finite extremes, phase/latency/dry-wet and bypass, independent final-output reconstruction, IR portability/failure, hold/tail retirement, MIDI ordering/repeated notes/pedals/chase, voice allocation, instrument release bounds and realtime cost/allocation diagnostics. Scalar tests alone do not establish project, native-host or offline behavior. For actual host/export integration, run `powershell -NoProfile -ExecutionPolicy Bypass -File tools/run-render-export-headless-regression.ps1`. This includes complete send publication, auxiliary outputs, processor preparation/PDC, full-state/factory/preset recall and actual exported release tails. Use the native lifecycle harness for real WebView boot/geometry/teardown. Browser fixtures separately establish layout, focus, gestures and callback pairing; mocked data is not audio evidence. Run the NAM headless suite after shared processor changes. @@ -462,6 +2225,30 @@ Record physical rectangles and DPI; verify title-bar movement, resize, minimize/ Release gates install/test the Windows EXE, exercise the mounted macOS DMG and launch the Linux AppImage under Xvfb/Openbox. On Linux, runtime assets resolve beside the running ELF through `/proc/self/exe`, including AppImage launches; inherited `APPDIR` cannot redirect resource lookup. Xvfb/Openbox qualifies X11/XWayland, not native Wayland. Pipeline definitions alone are not executed platform results. +For Windows issue [#7](https://github.com/sdevil7th/OpenStudio/issues/7), detected WebView2/VC++ versions and a successful prerequisite self-test are insufficient: the original Windows 10 report already detected both runtimes. Qualify the current installed **Release** executable from a non-elevated process, with no dev server, in normal and safe modes, and repeat with an unusable inherited fixed-runtime folder. Run this only after building/installing an executable that supports the offline lifecycle flag: + +```powershell +$exe = 'C:\Program Files\OpenStudio\OpenStudio.exe' +foreach ($mode in @('normal', 'safe')) { + foreach ($stale in @($false, $true)) { + $variant = if ($stale) { 'stale-runtime' } else { 'clean' } + ./tools/run-window-lifecycle-smoke.ps1 -AppPath $exe -StartupMode $mode ` + -StaleRuntimeOverride:$stale -RequireStandardUser ` + -ReportPath "$PWD/output/review/windows-$mode-$variant.json" + } +} +``` + +The wrapper refuses older binaries before launch and requires `--window-lifecycle-offline`: saved audio-device settings and hardware callback registration are skipped only for this explicit harness mode. The native report must prove no current audio device, zero device starts and zero callbacks; ordinary app startup is unchanged. Safe mode requires the recovery frontend in every detached browser role, including pitch, without claiming normal pitch editing. Normal mode retains the pitch edit/undo gates. Both require one ready document per main/detached browser through close/reopen cycles, packaged assets, and a configured/per-process WebView2 data path outside the install directory, including rejection of the install directory itself. Windows launches are suspended until assigned to an owned Job Object; cleanup terminates remaining owned descendants and requires the job to empty within five seconds, even after the app has exited. Cleanup failure makes qualification fail. Fixtures exercise both a surviving child after parent exit and a running parent, while preserving an unrelated process. + +Stale-runtime injection changes only the child process environment and tests a **nonexistent inherited fixed-runtime folder**; parent values are restored after launch. It does not cover a corrupt or wrong-architecture runtime, asynchronous environment initialization failure, or the exact Windows 10 Repair report. No installer or runtime repair is run. + +Keep each native report, adjacent startup log and `.qualification.json` together. The latter records executable path/hash/version, host OS, standard-user status and the injected path; it does not claim visual inspection, real audio qualification or Windows 10 coverage. Validate an existing artifact without launching an app with `-ValidateOnly -ReportPath -StartupMode normal|safe`. Windows CI runs the four cases from its already staged non-writable bundle and runs cleanup/validation fixtures; an elevated CI identity with denied bundle writes does not substitute for a Windows 10/11 standard-user installed run. Missing, malformed, nonzero hardware or incomplete detached-window evidence must fail closed. The reported Windows 10 22H2/build 19045 environment and endpoint-policy/Controlled Folder Access behavior remain separate real-machine checks; a build-26300 Debug pass cannot close them. + +To close the original report, test the installed candidate in Program Files on Windows 10 22H2/build 19045 using the same affected user and WebView profile: reproduce recovery, choose **Repair Dependencies**, restart, and repeat the normal/safe main-and-detached readiness matrix. Retain the exact installed executable hash plus startup and repair logs from before and after repair. A fresh-profile success is diagnostic and does not close a failure that persists in the original profile. + +On 22 October, local build-26300 qualification passed all four Debug readiness cases, then repeated Debug normal/clean with the corrected Job Object launcher. A fresh local Release build passed all four cases with that final launcher from a staged, non-writable bundle under a non-elevated token: 99 checks in each normal case and 90 in each safe case, with 10 ready frontend documents per case and zero hardware device starts/callbacks. The write-denial probe passed and the original bundle ACL was restored afterward. The Release executable SHA-256 was `001ac638e789a2193526604e03b556449f43edf770b660d1655c6a7c2b6fa8fd`; this was a local qualification build, without replacing or launching the installed Program Files artifact. Debug and Release builds produced no compiler warnings. Both used the frontend built at `2026-10-22T05:42:38Z`, whose packaged index matched `frontend/dist` (`f1ca2a24e6a094b394fa5e9c373e75bd583e9aa5846a584a72bf95f8204b97fd`). All 15 qualification fixtures and the focused Release `dynamics-audit` passed, including its 64 active-unity headroom cases; audio quality remains `not_asserted`. Exact reports, binary/frontend hashes and cleanup evidence are indexed by `output/review/windows-startup-20261022/qualification-summary.json`; the incomplete earlier attempt is excluded. These results qualify the tested mechanisms on this host, not the exact Windows 10 Repair scenario. + For each physical recording configuration, start with 60 seconds then a 30-minute sustained capture. Exercise the actual interface (including supported ASIO small buffers), WASAPI USB/webcam input/output pairs, Core Audio permission/aggregate configurations and Linux's shipped audio backend. Save `getAudioDebugSnapshot()` immediately before and after the take plus the original PCM WAV: ```text diff --git a/docs/ui-coverage-inventory.json b/docs/ui-coverage-inventory.json index 2f99df3..1d6bba1 100644 --- a/docs/ui-coverage-inventory.json +++ b/docs/ui-coverage-inventory.json @@ -1137,6 +1137,12 @@ "source": "frontend/src/components/builtin/PianoEditor.tsx", "coverage": [] }, + { + "id": "frontend/src/components/builtin/PluginFeatureFinder.tsx", + "kind": "component", + "source": "frontend/src/components/builtin/PluginFeatureFinder.tsx", + "coverage": [] + }, { "id": "frontend/src/components/builtin/PreampEditor.tsx", "kind": "component", diff --git a/frontend/e2e/audio-settings-switching.spec.ts b/frontend/e2e/audio-settings-switching.spec.ts index afdd38d..8f6592d 100644 --- a/frontend/e2e/audio-settings-switching.spec.ts +++ b/frontend/e2e/audio-settings-switching.spec.ts @@ -1,4 +1,19 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; +import type { AudioDeviceDraft, AudioDeviceSetupResponse } from "../src/services/NativeBridge"; +import type { useDAWStore } from "../src/store/useDAWStore"; + +interface AudioSettingsQa { + applied: AudioDeviceSetupResponse; + queries: AudioDeviceDraft[]; + applies: AudioDeviceDraft[]; + queryFailuresRemaining: number; + rejectApply: boolean; + rejectPanelRestart: boolean; + coerceAppliedBuffer?: number; + store: typeof useDAWStore; +} +declare global { interface Window { audioSettingsQa: AudioSettingsQa } } + test.beforeEach(async ({ page }) => { await page.addInitScript(() => { @@ -7,32 +22,87 @@ test.beforeEach(async ({ page }) => { }); await page.goto("/"); await expect(page.getByText("Starting OpenStudio...", { exact: true })).toBeHidden({ timeout: 15000 }); - await page.evaluate(async () => { - const { nativeBridge } = await import("/src/services/NativeBridge.ts"); - const { useDAWStore } = await import("/src/store/useDAWStore.ts"); - const setup = (type: string) => ({ - current: { audioDeviceType: type, inputDevice: type === "Initial" ? "Audient" : `${type} Input`, outputDevice: type === "Initial" ? "Audient" : `${type} Output`, sampleRate: 48000, bufferSize: 256, inputChannelNames: ["Input 1", "Input 2"], outputChannelNames: ["Out 1", "Out 2"], numInputChannels: 2, numOutputChannels: 2 }, - availableTypes: ["Initial", "ALSA", "JACK"], inputs: [type === "Initial" ? "Audient" : `${type} Input`], outputs: [type === "Initial" ? "Audient" : `${type} Output`], sampleRates: [48000], bufferSizes: [256, 512], - }); - let applied = setup("Initial"); - const queries: unknown[] = [], applies: unknown[] = []; - Object.assign(window, { audioSetupQueries: queries, audioSetupApplies: applies }); - nativeBridge.getAudioDeviceSetup = async () => applied; + +}); + +async function openSettingsFixture(page: Page, kind: "systems" | "systems-output-only" | "asio" | "asio-output-only" | "asio-input-only") { + await page.evaluate(async kind => { + const bridgeUrl = "/src/services/NativeBridge.ts"; + const storeUrl = "/src/store/useDAWStore.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + const { useDAWStore } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const asioDrivers = ["Audient ASIO", "Other ASIO"]; + const setup = (type: string, selectedDriver = "Audient ASIO"): AudioDeviceSetupResponse => { + const asio = type === "ASIO"; + const driver = asioDrivers.includes(selectedDriver) ? selectedDriver : asioDrivers[0]; + const inputDevice = asio ? driver : type === "Initial" ? "Audient" : `${type} Input`; + const outputDevice = asio ? driver : type === "Initial" ? "Audient" : `${type} Output`; + return { + current: { audioDeviceType: type, inputDevice, outputDevice, sampleRate: 48000, bufferSize: asio ? 8 : 256, + inputChannelNames: ["Input 1", "Input 2"], outputChannelNames: ["Out 1", "Out 2"], numInputChannels: 2, numOutputChannels: 2 }, + availableTypes: asio ? ["ASIO"] : ["Initial", "ALSA", "JACK"], + inputs: asio ? asioDrivers : [inputDevice], outputs: asio ? asioDrivers : [outputDevice], + sampleRates: [48000], bufferSizes: asio ? [8, 16, 32] : [256, 512], + }; + }; + const qa: AudioSettingsQa = window.audioSettingsQa = { + applied: setup(kind.startsWith("systems") ? "Initial" : "ASIO", kind === "asio-input-only" ? "Other ASIO" : undefined), queries: [], applies: [], + queryFailuresRemaining: 0, rejectApply: false, rejectPanelRestart: false, store: useDAWStore, + }; + if (kind === "asio-output-only") qa.applied.current.inputDevice = ""; + if (kind === "asio-input-only") qa.applied.current.outputDevice = ""; + if (kind === "systems-output-only") { + qa.applied.current.inputDevice = ""; + qa.applied.current.bufferSize = 8; + qa.applied.bufferSizes = [8, 16, 32]; + } + const resolve = (draft: AudioDeviceDraft) => { + const resolved = setup(draft.audioDeviceType, draft.outputDevice || draft.inputDevice); + if (draft.audioDeviceType === qa.applied.current.audioDeviceType) { + resolved.current.inputDevice = draft.inputDevice; + resolved.current.outputDevice = draft.outputDevice; + } + if (kind === "systems-output-only") resolved.bufferSizes = [8, 16, 32]; + if (resolved.sampleRates?.includes(draft.sampleRate)) resolved.current.sampleRate = draft.sampleRate; + if (resolved.bufferSizes?.includes(draft.bufferSize)) resolved.current.bufferSize = draft.bufferSize; + return resolved; + }; + nativeBridge.getAudioDeviceSetup = async () => qa.applied; nativeBridge.queryAudioDeviceSetup = async draft => { - queries.push(draft); - return setup(draft.audioDeviceType); + qa.queries.push({ ...draft }); + if (qa.queryFailuresRemaining > 0) { + --qa.queryFailuresRemaining; + return { ...qa.applied, sampleRates: [], bufferSizes: [], capabilityStatus: "unavailable", error: "Cannot inspect the running ASIO driver." }; + } + return resolve(draft); }; nativeBridge.applyAudioDeviceSetup = async draft => { - applies.push(draft); - if (draft.audioDeviceType === "JACK") return { success: false, error: "JACK has no available audio ports. Select ALSA.", setup: applied }; - applied = setup(draft.audioDeviceType); - return { success: true, setup: applied }; + qa.applies.push({ ...draft }); + if (qa.rejectApply) return { success: false, error: "ASIO rejected the requested buffer size.", setup: qa.applied }; + if (draft.audioDeviceType === "JACK") return { success: false, error: "JACK has no available audio ports. Select ALSA.", setup: qa.applied }; + qa.applied = resolve(draft); + if (qa.coerceAppliedBuffer) qa.applied.current.bufferSize = qa.coerceAppliedBuffer; + return { success: true, setup: qa.applied }; + }; + nativeBridge.openAudioDeviceControlPanel = async () => { + if (qa.rejectPanelRestart) { + qa.applied = { + ...qa.applied, sampleRates: [], bufferSizes: [], + current: { ...qa.applied.current, inputDevice: "", outputDevice: "", sampleRate: 0, bufferSize: 0, + inputChannelNames: [], outputChannelNames: [], numInputChannels: 0, numOutputChannels: 0, + numActiveInputChannels: 0, numActiveOutputChannels: 0 }, + }; + return { success: false, opened: true, restartRequested: true, + error: "The audio device did not reopen after its control panel closed." }; + } + return { success: true, opened: true, restartRequested: false }; }; useDAWStore.setState({ showSettings: true }); - }); -}); + }, kind); +} test("audio-system discovery resolves defaults without changing the live device", async ({ page }) => { + await openSettingsFixture(page, "systems"); const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); const audioSystem = dialog.getByLabel("Audio System", { exact: true }); await expect(audioSystem).toHaveValue("Initial"); @@ -40,17 +110,18 @@ test("audio-system discovery resolves defaults without changing the live device" await expect(dialog.getByLabel("Input Device", { exact: true })).toHaveValue("ALSA Input"); await expect(dialog.getByLabel("Output Device", { exact: true })).toHaveValue("ALSA Output"); const requests = await page.evaluate(() => ({ - queries: (window as any).audioSetupQueries, - applies: (window as any).audioSetupApplies, + queries: window.audioSettingsQa.queries, + applies: window.audioSettingsQa.applies, })); expect(requests.queries).toEqual([{ audioDeviceType: "ALSA", inputDevice: "Audient", outputDevice: "Audient", sampleRate: 0, bufferSize: 0 }]); expect(requests.applies).toEqual([]); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(dialog).toBeHidden(); - expect(await page.evaluate(async () => (await import("/src/services/NativeBridge.ts")).nativeBridge.getAudioDeviceSetup())).toMatchObject({ current: { audioDeviceType: "Initial" } }); + expect(await page.evaluate(() => window.audioSettingsQa.applied)).toMatchObject({ current: { audioDeviceType: "Initial" } }); }); test("failed Apply publishes recovery while keeping the pending draft for correction", async ({ page }) => { + await openSettingsFixture(page, "systems"); const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); const audioSystem = dialog.getByLabel("Audio System", { exact: true }); await expect(audioSystem).toHaveValue("Initial"); @@ -59,14 +130,186 @@ test("failed Apply publishes recovery while keeping the pending draft for correc await dialog.getByRole("button", { name: "Apply", exact: true }).click(); await expect(dialog.getByText("JACK has no available audio ports. Select ALSA.")).toBeVisible(); await expect(audioSystem).toHaveValue("JACK"); - expect(await page.evaluate(async () => (await import("/src/services/NativeBridge.ts")).nativeBridge.getAudioDeviceSetup())).toMatchObject({ current: { audioDeviceType: "Initial" } }); + expect(await page.evaluate(() => window.audioSettingsQa.applied)).toMatchObject({ current: { audioDeviceType: "Initial" } }); await audioSystem.selectOption("ALSA"); await expect(dialog.getByLabel("Input Device", { exact: true })).toHaveValue("ALSA Input"); await dialog.getByRole("button", { name: "OK", exact: true }).click(); await expect(dialog).toBeHidden(); - expect(await page.evaluate(async () => (await import("/src/services/NativeBridge.ts")).nativeBridge.getAudioDeviceSetup())).toMatchObject({ current: { audioDeviceType: "ALSA" } }); - expect(await page.evaluate(() => (window as any).audioSetupApplies)).toEqual([ + expect(await page.evaluate(() => window.audioSettingsQa.applied)).toMatchObject({ current: { audioDeviceType: "ALSA" } }); + expect(await page.evaluate(() => window.audioSettingsQa.applies)).toEqual([ { audioDeviceType: "JACK", inputDevice: "JACK Input", outputDevice: "JACK Output", sampleRate: 48000, bufferSize: 256 }, { audioDeviceType: "ALSA", inputDevice: "ALSA Input", outputDevice: "ALSA Output", sampleRate: 48000, bufferSize: 256 }, ]); }); + +test("ASIO buffer selection keeps 16 pending and sends 16 to Apply while the active device stays at 8", async ({ page }) => { + await openSettingsFixture(page, "asio"); + const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); + const buffer = dialog.getByLabel("Buffer Size", { exact: true }); + await expect(buffer).toHaveValue("8"); + await buffer.selectOption("16"); + await expect(buffer).toHaveValue("16"); + expect(await page.evaluate(() => ({ queries: window.audioSettingsQa.queries, applied: window.audioSettingsQa.applied.current.bufferSize, applies: window.audioSettingsQa.applies }))) + .toEqual({ queries: [{ audioDeviceType: "ASIO", inputDevice: "Audient ASIO", outputDevice: "Audient ASIO", sampleRate: 48000, bufferSize: 16 }], applied: 8, applies: [] }); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(buffer).toHaveValue("16"); + await expect.poll(() => page.evaluate(() => window.audioSettingsQa.applied.current.bufferSize)).toBe(16); + expect(await page.evaluate(() => window.audioSettingsQa.applies.map(draft => draft.bufferSize))).toEqual([16]); +}); + +test("ASIO successful coercion keeps OK open and reports requested versus accepted buffer", async ({ page }) => { + await openSettingsFixture(page, "asio"); + const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); + const buffer = dialog.getByLabel("Buffer Size", { exact: true }); + await buffer.selectOption("16"); + await expect(buffer).toHaveValue("16"); + await page.evaluate(() => { window.audioSettingsQa.coerceAppliedBuffer = 8; }); + await dialog.getByRole("button", { name: "OK", exact: true }).click(); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("status").filter({ hasText: "Requested 16 samples; the driver accepted 8 samples." })).toBeVisible(); + await expect(buffer).toHaveValue("8"); + expect(await page.evaluate(() => window.audioSettingsQa.applies.map(draft => draft.bufferSize))).toEqual([16]); + await dialog.getByLabel("ASIO Driver", { exact: true }).selectOption("Other ASIO"); + await expect(dialog.getByLabel("ASIO Driver", { exact: true })).toHaveValue("Other ASIO"); + await expect(dialog.getByRole("status").filter({ hasText: "Requested 16 samples; the driver accepted 8 samples." })).toBeHidden(); + await expect(dialog.getByRole("status").filter({ hasText: "Changes are pending" })).toBeVisible(); + expect(await page.evaluate(() => window.audioSettingsQa.applied.current.outputDevice)).toBe("Audient ASIO"); + await dialog.getByRole("button", { name: "OK", exact: true }).click(); + await expect(dialog).toBeHidden(); +}); + +test("failed ASIO discovery preserves pending 16, blocks Apply and supports Retry and correction", async ({ page }) => { + await openSettingsFixture(page, "asio"); + const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); + const buffer = dialog.getByLabel("Buffer Size", { exact: true }); + await expect(buffer).toHaveValue("8"); + await page.evaluate(() => { window.audioSettingsQa.queryFailuresRemaining = 1; }); + await buffer.selectOption("16"); + await expect(dialog.getByText("Cannot inspect the running ASIO driver.", { exact: false })).toBeVisible(); + await expect(buffer).toHaveValue("16"); + await expect(buffer.locator('option[value="16"]')).toHaveText("16 samples (pending)"); + await expect(buffer.locator('option[value="16"]')).toBeDisabled(); + await expect(buffer.locator('option[value="8"]')).toHaveCount(0); + await expect(dialog.getByLabel("Sample Rate", { exact: true })).toHaveValue("48000"); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "OK", exact: true })).toBeDisabled(); + expect(await page.evaluate(() => window.audioSettingsQa.applied.current.bufferSize)).toBe(8); + await dialog.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(dialog.getByText("Cannot inspect the running ASIO driver.", { exact: false })).toBeHidden(); + await expect(buffer).toHaveValue("16"); + await expect(buffer.locator('option[value="16"]')).toBeEnabled(); + expect(await page.evaluate(() => window.audioSettingsQa.queries.map(draft => draft.bufferSize))).toEqual([16, 16]); + await buffer.selectOption("32"); + await expect(buffer).toHaveValue("32"); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.audioSettingsQa.applied.current.bufferSize)).toBe(32); + expect(await page.evaluate(() => window.audioSettingsQa.applies.map(draft => draft.bufferSize))).toEqual([32]); +}); + +test("failed ASIO Apply retains pending 16 and reports actual 8 before a successful retry", async ({ page }) => { + await openSettingsFixture(page, "asio"); + const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); + const buffer = dialog.getByLabel("Buffer Size", { exact: true }); + await expect(buffer).toHaveValue("8"); + await buffer.selectOption("16"); + await expect(buffer).toHaveValue("16"); + await page.evaluate(() => { window.audioSettingsQa.rejectApply = true; }); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(dialog.getByText("ASIO rejected the requested buffer size.", { exact: false })).toBeVisible(); + await expect(buffer).toHaveValue("16"); + expect(await page.evaluate(() => ({ active: window.audioSettingsQa.applied.current.bufferSize, published: window.audioSettingsQa.store.getState().audioDeviceSetup?.bufferSize }))) + .toEqual({ active: 8, published: 8 }); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); + await page.evaluate(() => { window.audioSettingsQa.rejectApply = false; }); + await dialog.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.audioSettingsQa.applied.current.bufferSize)).toBe(16); + expect(await page.evaluate(() => window.audioSettingsQa.applies.map(draft => draft.bufferSize))).toEqual([16, 16]); +}); + +for (const direction of ["output-only", "input-only"] as const) { + test(`ASIO ${direction} scalar edits preserve the disabled side and show the active driver`, async ({ page }) => { + await openSettingsFixture(page, `asio-${direction}`); + const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); + const driver = dialog.getByLabel("ASIO Driver", { exact: true }); + const selectedDriver = direction === "input-only" ? "Other ASIO" : "Audient ASIO"; + const names = { + inputDevice: direction === "input-only" ? selectedDriver : "", + outputDevice: direction === "output-only" ? selectedDriver : "", + }; + await expect(driver).toHaveValue(selectedDriver); + const buffer = dialog.getByLabel("Buffer Size", { exact: true }); + await expect(buffer).toHaveValue("8"); + await buffer.selectOption("16"); + await expect(buffer).toHaveValue("16"); + expect(await page.evaluate(() => window.audioSettingsQa.queries)) + .toEqual([{ audioDeviceType: "ASIO", ...names, sampleRate: 48000, bufferSize: 16 }]); + expect(await page.evaluate(() => window.audioSettingsQa.applied.current)) + .toMatchObject({ ...names, bufferSize: 8 }); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.audioSettingsQa.applied.current)) + .toMatchObject({ ...names, bufferSize: 16 }); + expect(await page.evaluate(() => window.audioSettingsQa.applies)) + .toEqual([{ audioDeviceType: "ASIO", ...names, sampleRate: 48000, bufferSize: 16 }]); + + const nextDriver = direction === "input-only" ? "Audient ASIO" : "Other ASIO"; + await driver.selectOption(nextDriver); + await expect(driver).toHaveValue(nextDriver); + expect(await page.evaluate(() => { + const queries = window.audioSettingsQa.queries; + return queries[queries.length - 1]; + })) + .toEqual({ audioDeviceType: "ASIO", inputDevice: nextDriver, outputDevice: nextDriver, sampleRate: 0, bufferSize: 0 }); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.audioSettingsQa.applied.current)) + .toMatchObject({ inputDevice: nextDriver, outputDevice: nextDriver }); + }); +} + +test("separate output-only endpoints stay visibly disabled while changing buffer8 to16", async ({ page }) => { + await openSettingsFixture(page, "systems-output-only"); + const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); + const input = dialog.getByLabel("Input Device", { exact: true }); + const output = dialog.getByLabel("Output Device", { exact: true }); + await expect(input).toHaveValue(""); + await expect(input.locator('option:checked')).toHaveText("Disabled"); + await expect(output).toHaveValue("Audient"); + const buffer = dialog.getByLabel("Buffer Size", { exact: true }); + await expect(buffer).toHaveValue("8"); + await buffer.selectOption("16"); + await expect(buffer).toHaveValue("16"); + await expect(input).toHaveValue(""); + expect(await page.evaluate(() => window.audioSettingsQa.queries)) + .toEqual([{ audioDeviceType: "Initial", inputDevice: "", outputDevice: "Audient", sampleRate: 48000, bufferSize: 16 }]); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.audioSettingsQa.applied.current)) + .toMatchObject({ inputDevice: "", outputDevice: "Audient", bufferSize: 16 }); + expect(await page.evaluate(() => window.audioSettingsQa.applies)) + .toEqual([{ audioDeviceType: "Initial", inputDevice: "", outputDevice: "Audient", sampleRate: 48000, bufferSize: 16 }]); +}); + +test("failed ASIO panel restart publishes no open device and supports selecting a recovery device", async ({ page }) => { + await openSettingsFixture(page, "asio"); + const dialog = page.getByRole("dialog", { name: "Audio Settings", exact: true }); + const buffer = dialog.getByLabel("Buffer Size", { exact: true }); + await expect(buffer).toHaveValue("8"); + await page.evaluate(() => { window.audioSettingsQa.rejectPanelRestart = true; }); + await dialog.getByRole("button", { name: "Open ASIO Control Panel", exact: true }).click(); + await expect(dialog.getByText("The audio device did not reopen after its control panel closed.", { exact: true })).toBeVisible(); + await expect(dialog.getByText("No audio device is currently open. Select a device and choose Apply to restore audio.", { exact: true })).toBeVisible(); + await expect(buffer).toHaveValue("0"); + await expect(buffer.locator('option[value="8"]')).toHaveCount(0); + await expect(dialog.getByLabel("Sample Rate", { exact: true })).toHaveValue("0"); + await expect(dialog.getByLabel("ASIO Driver", { exact: true })).toHaveValue(""); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); + expect(await page.evaluate(() => window.audioSettingsQa.store.getState().audioDeviceSetup)) + .toMatchObject({ inputDevice: "", outputDevice: "", sampleRate: 0, bufferSize: 0 }); + + await dialog.getByLabel("ASIO Driver", { exact: true }).selectOption("Audient ASIO"); + await expect(buffer).toHaveValue("8"); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.audioSettingsQa.store.getState().audioDeviceSetup)) + .toMatchObject({ inputDevice: "Audient ASIO", outputDevice: "Audient ASIO", sampleRate: 48000, bufferSize: 8 }); + await expect(dialog.getByText("No audio device is currently open. Select a device and choose Apply to restore audio.", { exact: true })).toBeHidden(); +}); diff --git a/frontend/e2e/editor-gesture-regressions.spec.ts b/frontend/e2e/editor-gesture-regressions.spec.ts new file mode 100644 index 0000000..6544a4e --- /dev/null +++ b/frontend/e2e/editor-gesture-regressions.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; +import { runNamOctaverFuzzChecks } from "./support/namOctaverFuzzChecks"; +import { runRulerChecks } from "./support/rulerChecks"; +import { runTimelineCopyChecks } from "./support/timelineCopyChecks"; + +test("the visible App ruler preserves zoom, selection, handle and cancellation contracts", async ({ page }) => { + const result = await runRulerChecks(page); + expect(result.surface).toBe("real App .workspace-sticky-ruler"); + expect(result.checks).toHaveLength(9); +}); + +test("selected audio and MIDI clips copy together with matching previews and atomic history", async ({ page }) => { + test.setTimeout(90_000); + const result = await runTimelineCopyChecks(page); + expect(result.status).toBe("pass"); + expect(result.checks.length).toBeGreaterThan(0); +}); + +test("Octaver Fuzz fits four viewport widths and retains independent stage state", async ({ page }) => { + test.setTimeout(90_000); + const result = await runNamOctaverFuzzChecks(page); + expect(result.checks).toHaveLength(7); +}); diff --git a/frontend/e2e/free-suite-design03.spec.ts b/frontend/e2e/free-suite-design03.spec.ts new file mode 100644 index 0000000..ee669a9 --- /dev/null +++ b/frontend/e2e/free-suite-design03.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; +import { installFreeSuiteHarness, loadNativeFreeSuiteSchemas, mountFreeSuiteEditor, nativeFreeSuiteSchemasUnavailable } from "./support/freeSuiteHarness"; + +// Generate this fixture with the native editor-schemas regression group, or set +// OPENSTUDIO_FREE_SUITE_SCHEMAS. A browser fixture does not assert DSP quality. +test.skip(nativeFreeSuiteSchemasUnavailable, "Export native schemas and run this suite with: powershell -File tools/run-free-suite-ui-regression.ps1 (from the repository root)"); +test.beforeEach(async ({ page }) => installFreeSuiteHarness(page, loadNativeFreeSuiteSchemas())); +test.afterEach(async ({ page }) => page.evaluate(() => window.freeSuiteQA?.dispose())); + +test("instrument primary views fit compact, preferred and 4K hosts with pinned output", async ({ page }, testInfo) => { + for (const size of [{ width: 640, height: 480 }, { width: 1040, height: 680 }, { width: 3840, height: 2160 }]) { + await page.setViewportSize(size); + for (const pluginId of ["synth", "piano", "guitar", "drums"]) { + const editor = await mountFreeSuiteEditor(page, pluginId); + const geometry = await editor.evaluate(root => { + const workspace = root.querySelector(".instrument-workspace")!; + const output = root.querySelector('[data-param="outputGain"]')!.getBoundingClientRect(); + const outside = Array.from(root.querySelectorAll("input,select,button,[role=slider]")) + .filter(element => element.getClientRects().length).filter(element => { + const rect = element.getBoundingClientRect(); return rect.left < -1 || rect.right > innerWidth + 1; + }); + return { horizontal: workspace.scrollWidth - workspace.clientWidth, outside: outside.length, outputTop: output.top, outputBottom: output.bottom }; + }); + expect(geometry.horizontal).toBeLessThanOrEqual(1); + expect(geometry.outside).toBe(0); + expect(geometry.outputTop).toBeGreaterThan(0); + expect(geometry.outputBottom).toBeLessThanOrEqual(size.height); + await page.screenshot({ path: testInfo.outputPath(`${pluginId}-${size.width}.png`) }); + } + } +}); + +test("compact Synth navigation preserves editable routes and native gestures", async ({ page }) => { + await page.setViewportSize({ width: 640, height: 480 }); + const editor = await mountFreeSuiteEditor(page, "synth"); + await editor.getByRole("button", { name: "Filter", exact: true }).click(); + const cutoff = editor.getByRole("slider", { name: "Cutoff", exact: true }); + await expect(cutoff).toHaveAttribute("aria-disabled", "true"); + await editor.getByRole("combobox", { name: "Filter type", exact: true }).selectOption("1"); + await expect(cutoff).not.toHaveAttribute("aria-disabled", "true"); + await cutoff.focus(); await cutoff.press("ArrowUp"); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.writes.some(write => write.id === "filterCutoff"))).toBe(true); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.gestures.some(edit => edit.id === "filterCutoff" && !edit.begin))).toBe(true); + await editor.getByRole("button", { name: "Modulation", exact: true }).click(); + await expect(editor.getByRole("combobox", { name: "Route 8 destination", exact: true })).toBeVisible(); + await editor.getByRole("button", { name: "Macros", exact: true }).click(); + await expect(editor.getByRole("slider", { name: "Macro 4", exact: true })).toBeVisible(); + await editor.getByRole("spinbutton", { name: "Macro 4 value", exact: true }).fill("35"); + await editor.getByRole("spinbutton", { name: "Macro 4 value", exact: true }).press("Enter"); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.writes.some(write => write.id === "macro4" && write.value === .35))).toBe(true); +}); + +test("compact drum banks keep note-off ownership and exclusive output controls", async ({ page }) => { + await page.setViewportSize({ width: 640, height: 480 }); + const editor = await mountFreeSuiteEditor(page, "drums", { articulationEngine: 1 }); + const kick = editor.getByRole("button", { name: /Audition Kick, MIDI input 36/ }); + await kick.focus(); await kick.press("Enter"); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.audition.some(event => event.note === 36 && !event.on))).toBe(true); + await editor.getByRole("button", { name: "Cymbals & extras", exact: true }).click(); + await expect(kick).toBeHidden(); + await editor.getByRole("button", { name: "Selected piece", exact: true }).click(); + await expect(editor.getByRole("combobox", { name: "Output", exact: true })).toBeVisible(); + await editor.getByRole("button", { name: "Character", exact: true }).click(); + await expect(editor.locator('[data-param="hihatTightness"] [role="slider"]')).toHaveAttribute("aria-disabled", "true"); + await expect(editor.getByText("Hi-hat Tightness and Kit apply to Legacy voices. Their values are retained.")).toBeVisible(); +}); + +test("Guitar sound and Piano voicing remain reachable with the keyboard visible", async ({ page }) => { + await page.setViewportSize({ width: 640, height: 480 }); + for (const pluginId of ["guitar", "piano"]) { + const editor = await mountFreeSuiteEditor(page, pluginId); + const tone = editor.getByRole("slider", { name: "Tone", exact: true }); + await expect(tone).toBeVisible(); + const box = await tone.boundingBox(); + const footer = await editor.locator(".instrument-keyboard-section").boundingBox(); + expect(box!.y + box!.height).toBeLessThanOrEqual(footer!.y); + const audition = editor.getByRole("button", { name: "Audition C3, MIDI 48", exact: true }); + // Space remains the profile's transport shortcut; Enter auditions a focused key. + await audition.focus(); await audition.press("Enter"); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.audition.some(event => event.note === 48 && !event.on))).toBe(true); + } +}); + +test("feature search reveals compact controls and the owned drum mapping dialog without changing sound", async ({ page }) => { + await page.setViewportSize({ width: 640, height: 480 }); + const synth = await mountFreeSuiteEditor(page, "synth"); + await synth.getByRole("button", { name: "Find a control or feature", exact: true }).click(); + await page.getByLabel("Search controls and features", { exact: true }).fill("matrix8TargetFull"); + await page.getByRole("button", { name: /^Route 8 Destination/ }).click(); + await expect(synth.getByRole("combobox", { name: "Route 8 destination", exact: true })).toBeFocused(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + + const drums = await mountFreeSuiteEditor(page, "drums"); + await drums.getByRole("button", { name: "Find a control or feature", exact: true }).click(); + await page.getByLabel("Search controls and features", { exact: true }).fill("Drum map and MIDI learn"); + await page.getByRole("button", { name: /^Drum map and MIDI learn/ }).click(); + const map = page.getByRole("dialog", { name: "Drum MIDI map", exact: true }); + await map.getByRole("combobox", { name: "Incoming drum note", exact: true }).selectOption("127"); + await map.getByRole("combobox", { name: "Drum destination note", exact: true }).selectOption("-1"); + await map.getByRole("button", { name: "Cancel", exact: true }).click(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + expect(await page.evaluate(() => window.freeSuiteQA.schema.parameters.find(parameter => parameter.id === "noteMap127")?.value)).toBe(127); +}); diff --git a/frontend/e2e/free-suite-effects.spec.ts b/frontend/e2e/free-suite-effects.spec.ts new file mode 100644 index 0000000..85d53dc --- /dev/null +++ b/frontend/e2e/free-suite-effects.spec.ts @@ -0,0 +1,320 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installFreeSuiteHarness, loadNativeFreeSuiteSchemas, mountFreeSuiteEditor, nativeFreeSuiteSchemasUnavailable } from "./support/freeSuiteHarness"; + +test.skip(nativeFreeSuiteSchemasUnavailable, "Export current native schemas with tools/run-free-suite-ui-regression.ps1 before this qualification suite."); +test.beforeEach(async ({ page }) => installFreeSuiteHarness(page, loadNativeFreeSuiteSchemas())); +test.afterEach(async ({ page }) => page.evaluate(() => window.freeSuiteQA?.dispose())); + +interface ReverbResponseQA { + pending: boolean; + calls: { action: string; decay: number }[]; + finishWrite: (success: boolean) => void; +} +declare global { interface Window { reverbResponseQA: ReverbResponseQA } } + +async function deferReverbWrite(page: Page) { + await page.evaluate(async () => { + const path = "/src/services/NativeBridge.ts"; + const { nativeBridge: bridge } = await import(/* @vite-ignore */ path) as typeof import("../src/services/NativeBridge"); + const write = bridge.setBuiltInPluginParam.bind(bridge); + const state: ReverbResponseQA = window.reverbResponseQA = { pending: false, calls: [], finishWrite: () => {} }; + bridge.setBuiltInPluginParam = (address, id, value) => new Promise(resolve => { + state.pending = true; + state.finishWrite = success => { + state.pending = false; + if (success) void write(address, id, value).then(resolve); + else resolve(false); + }; + }); + bridge.reverbResponse = async action => { + state.calls.push({ action, decay: window.freeSuiteQA.schema.parameters.find(p => p.id === "decayTime")!.value }); + return { success: true, complete: true, seconds: 5, sampleRate: 48000, tempoBpm: 120, + peak: [Array(640).fill(.01), Array(640).fill(.01)], + rms: [Array(640).fill(.005), Array(640).fill(.005)] }; + }; + }); +} + +for (const route of ["desktop", "Find"] as const) { + for (const success of [true, false]) { + test(`Reverb ${route} response waits for pending parameter writes and ${success ? "captures the accepted value" : "rejects a failed write"}`, async ({ page }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + const editor = await mountFreeSuiteEditor(page, "reverb", { reverbTypeAll: 0, decayTime: 2 }); + await deferReverbWrite(page); + const decay = editor.locator('[data-param="decayTime"] input[type=number]').first(); + await decay.fill("3.25"); await decay.press("Enter"); + await expect.poll(() => page.evaluate(() => window.reverbResponseQA.pending)).toBe(true); + if (route === "Find") { + await editor.getByRole("button", { name: "Find a control or feature" }).click(); + await page.getByRole("textbox", { name: "Search controls and features" }).fill("Response analysis"); + await page.getByLabel("Search results").getByRole("button").first().click(); + await expect(editor.getByRole("tab", { name: "Response", exact: true })).toHaveAttribute("aria-selected", "true"); + } + const render = editor.getByRole("button", { name: "Render response", exact: true }); + await render.click(); + await expect(render).toBeDisabled(); + // Let the UI settle while the real parameter writer is still in flight. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + expect(await page.evaluate(() => window.reverbResponseQA.calls)).toEqual([]); + await page.evaluate(success => window.reverbResponseQA.finishWrite(success), success); + if (success) { + await expect(editor.getByLabel("Response measurements")).toBeVisible(); + expect(await page.evaluate(() => window.reverbResponseQA.calls.filter(call => call.action === "render"))).toEqual([{ action: "render", decay: 3.25 }]); + } else { + await expect(editor.getByRole("alert").filter({ hasText: "Pending parameter changes could not be applied" })).toBeVisible(); + expect(await page.evaluate(() => window.reverbResponseQA.calls)).toEqual([]); + } + await expect(render).toBeEnabled(); + }); + } +} + +test("Reverb response cancellation while flushing never starts a late render", async ({ page }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + const editor = await mountFreeSuiteEditor(page, "reverb", { reverbTypeAll: 0, decayTime: 2 }); + await deferReverbWrite(page); + const decay = editor.locator('[data-param="decayTime"] input[type=number]').first(); + await decay.fill("3.25"); await decay.press("Enter"); + await expect.poll(() => page.evaluate(() => window.reverbResponseQA.pending)).toBe(true); + await editor.getByRole("button", { name: "Render response", exact: true }).click(); + await editor.getByRole("button", { name: "Cancel response", exact: true }).click(); + await page.evaluate(() => window.reverbResponseQA.finishWrite(true)); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.writes.some(write => write.id === "decayTime" && write.value === 3.25))).toBe(true); + await expect(editor.getByRole("button", { name: "Render response", exact: true })).toBeEnabled(); + expect(await page.evaluate(() => window.reverbResponseQA.calls)).toEqual([]); +}); + +test("Reverb response stays inside its workbench above output controls", async ({ page }, testInfo) => { + for (const viewport of [{ width: 900, height: 680 }, { width: 1920, height: 1080 }, { width: 3840, height: 2160 }]) { + await page.setViewportSize(viewport); + const editor = await mountFreeSuiteEditor(page, "reverb", { reverbTypeAll: 1 }); + if (viewport.width >= 1920) { + const response = editor.getByRole("complementary", { name: "Response analysis" }); + await expect(response).toBeVisible(); + const bounds = await response.evaluate(element => { + const panel = element.getBoundingClientRect(); + const text = element.querySelector("p")!.getBoundingClientRect(); + const output = element.closest(".reverb-console")!.querySelector(".reverb-output")!.getBoundingClientRect(); + return { panelBottom: panel.bottom, textBottom: text.bottom, outputTop: output.top, overflow: element.scrollHeight - element.clientHeight }; + }); + expect(bounds.textBottom).toBeLessThanOrEqual(bounds.panelBottom + 1); + expect(bounds.panelBottom).toBeLessThanOrEqual(bounds.outputTop + 1); + expect(bounds.overflow).toBeLessThanOrEqual(1); + } + await page.mouse.move(0, 0); + await page.screenshot({ path: testInfo.outputPath(`free-suite-reverb-${viewport.width}.png`) }); + } +}); + +test("effect controls fit minimum, preferred and large editor hosts", async ({ page }, testInfo) => { + test.setTimeout(120_000); + for (const size of [{ width: 640, height: 480 }, { width: 1040, height: 680 }, { width: 1920, height: 1080 }, { width: 3840, height: 2160 }]) { + await page.setViewportSize(size); + for (const pluginId of ["eq", "geq", "compressor", "gate", "limiter", "preamp", "saturator", "reverb", "delay", "chorus", "utility", "pitch"]) { + const editor = await mountFreeSuiteEditor(page, pluginId); + await expect(editor.getByRole("button", { name: "Find a control or feature" })).toBeVisible(); + const overflow = await editor.evaluate(root => Array.from(root.querySelectorAll("input,select,button,[role=slider]")) + .filter(element => element.getClientRects().length && getComputedStyle(element).visibility !== "hidden") + .filter(element => { const rect = element.getBoundingClientRect(); return rect.left < -1 || rect.right > innerWidth + 1; }) + .map(element => element.getAttribute("aria-label") || element.textContent)); + expect(overflow, `${pluginId} at ${size.width}`).toEqual([]); + await page.screenshot({ path: testInfo.outputPath(`${pluginId}-${size.width}.png`) }); + } + } +}); + +test("exact entry, fine keyboard, reset and Undo keep native gesture boundaries", async ({ page }) => { + const editor = await mountFreeSuiteEditor(page, "gate"); + const value = editor.getByRole("spinbutton", { name: "Open threshold value", exact: true }); + await value.fill("-36.5"); await value.press("Enter"); + await expect(value).toHaveValue("-36.5"); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.gestures.filter(edit => edit.id === "threshold").map(edit => edit.begin))).toEqual([true, false]); + const slider = editor.getByRole("slider", { name: "Open threshold", exact: true }); + await slider.focus(); await slider.press("Shift+ArrowUp"); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.writes.filter(write => write.id === "threshold").length)).toBe(2); + await slider.dblclick(); + const defaultValue = await page.evaluate(() => window.freeSuiteQA.schema.parameters.find(parameter => parameter.id === "threshold")!.defaultValue); + await expect(slider).toHaveAttribute("aria-valuenow", String(defaultValue)); + await editor.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(slider).not.toHaveAttribute("aria-valuenow", String(defaultValue)); + await value.fill("-12"); await value.press("Escape"); + await expect(value).not.toHaveValue("-12"); +}); + +test("Find reveals secondary controls without changing processing modes", async ({ page }) => { + await page.setViewportSize({ width: 640, height: 480 }); + const editor = await mountFreeSuiteEditor(page, "chorus"); + await editor.getByRole("button", { name: "Find a control or feature" }).click(); + await page.getByRole("textbox", { name: "Search controls and features" }).fill("highCut"); + await page.getByLabel("Search results").getByRole("button").first().click(); + await expect(editor.getByRole("slider", { name: "High cut", exact: true })).toBeFocused(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + await mountFreeSuiteEditor(page, "delay"); + await editor.getByRole("button", { name: "Find a control or feature" }).click(); + await page.getByRole("textbox", { name: "Search controls and features" }).fill("dualFeedback"); + await page.getByLabel("Search results").getByRole("button").first().click(); + await expect(page.getByRole("status").filter({ hasText: "not active in the current configuration" })).toBeVisible(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); +}); + +test("Gate expansion and Classic limiter clearly retain inactive settings", async ({ page }) => { + let editor = await mountFreeSuiteEditor(page, "gate", { expansionMode: 1, holdMs: 80, hysteresis: 6 }); + await expect(editor.getByRole("slider", { name: "Hold", exact: true })).toHaveAttribute("aria-disabled", "true"); + await expect(editor.getByRole("slider", { name: "Hysteresis", exact: true })).toHaveAttribute("aria-disabled", "true"); + await expect(editor.getByText("Expander uses a continuous gain curve.", { exact: false })).toBeVisible(); + editor = await mountFreeSuiteEditor(page, "limiter", { limitingStyleAll: 0 }); + await editor.getByRole("button", { name: "Timing & linking", exact: true }).click(); + await expect(editor.getByRole("slider", { name: "Attack", exact: true })).toHaveAttribute("aria-disabled", "true"); + await expect(editor.getByRole("slider", { name: "Release", exact: true })).not.toHaveAttribute("aria-disabled", "true"); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); +}); + +test("Convolution Find opens its real shaping pages without preparing audio", async ({ page }) => { + const editor = await mountFreeSuiteEditor(page, "reverb", { reverbTypeAll: 7 }); + await editor.getByRole("button", { name: "Find a control or feature" }).click(); + await page.getByRole("textbox", { name: "Search controls and features" }).fill("Convolution Damping"); + await page.getByLabel("Search results").getByRole("button").first().click(); + await expect(editor.locator('[data-feature="ir-damping"]')).toBeVisible(); + await expect(editor.getByRole("button", { name: "Damping", exact: true })).toHaveAttribute("aria-pressed", "true"); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); +}); + +test("Reverb Setup keeps saved return choices and independent balance", async ({ page }) => { + for (const type of [0, 2, 4, 7, 19, 36, 40]) { + const editor = await mountFreeSuiteEditor(page, "reverb", { reverbTypeAll: type, reverbReturnMode: 0, wetLevel: .33, dryLevel: .7 }); + await editor.getByRole("tab", { name: /^Setup/ }).click(); + await expect(editor.getByRole("combobox", { name: "Return level", exact: true })).toHaveValue("0"); + await expect(editor.locator('[data-param="wetLevel"] [role=slider]')).toHaveAttribute("aria-valuenow", "0.33"); + await expect(editor.locator('[data-param="dryLevel"] [role=slider]')).toHaveAttribute("aria-valuenow", "0.7"); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + } +}); + +test("parallel mix exact entry and A/B copy preserve values through Undo", async ({ page }) => { + const editor = await mountFreeSuiteEditor(page, "compressor"); + const mix = editor.getByRole("spinbutton", { name: "Parallel mix value", exact: true }); + await mix.fill("37.5"); await mix.press("Enter"); + await expect(mix).toHaveValue("37.5"); + await editor.getByRole("button", { name: "B", exact: true }).click(); + await expect(mix).toHaveValue("100"); + await mix.fill("62.5"); await mix.press("Enter"); + await editor.getByRole("button", { name: "Copy B to A", exact: true }).click(); + await editor.getByRole("button", { name: "A", exact: true }).click(); + await expect(mix).toHaveValue("62.5"); + await mix.fill("-20"); await mix.press("Escape"); + await expect(mix).toHaveValue("62.5"); + await mix.fill("40"); await mix.press("Enter"); + await editor.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(mix).toHaveValue("62.5"); +}); + +test("disabling a captured parameter drag closes its gesture and preserves the retained value", async ({ page }) => { + await page.setViewportSize({ width: 1040, height: 680 }); + const editor = await mountFreeSuiteEditor(page, "gate", { expansionMode: 0, holdMs: 80 }); + const hold = editor.getByRole("slider", { name: "Hold", exact: true }); + await hold.scrollIntoViewIfNeeded(); + const bounds = await hold.boundingBox(); + expect(bounds).not.toBeNull(); + const x = bounds!.x + bounds!.width / 2, y = bounds!.y + bounds!.height / 2; + await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x, y - 12); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.writes.filter(write => write.id === "holdMs").length)).toBeGreaterThan(0); + await page.evaluate(() => window.freeSuiteQA.update({ expansionMode: 1 })); + await expect(hold).toHaveAttribute("aria-disabled", "true"); + // The physical pointer is still down: disabling must close the native gesture immediately. + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.gestures.filter(edit => edit.id === "holdMs").map(edit => edit.begin))).toEqual([true, false]); + const retained = await page.evaluate(() => ({ + value: window.freeSuiteQA.schema.parameters.find(parameter => parameter.id === "holdMs")!.value, + writes: window.freeSuiteQA.writes.filter(write => write.id === "holdMs").length, + })); + await page.mouse.move(x, y - 40); await page.mouse.up(); + expect(await page.evaluate(() => ({ + value: window.freeSuiteQA.schema.parameters.find(parameter => parameter.id === "holdMs")!.value, + writes: window.freeSuiteQA.writes.filter(write => write.id === "holdMs").length, + }))).toEqual(retained); + expect(await page.evaluate(() => window.freeSuiteQA.gestures.filter(edit => edit.id === "holdMs").map(edit => edit.begin))).toEqual([true, false]); + await page.evaluate(() => window.freeSuiteQA.update({ expansionMode: 0 })); + const numeric = editor.getByRole("spinbutton", { name: "Hold value", exact: true }); + await numeric.fill("123.4567"); + await page.evaluate(() => window.freeSuiteQA.update({ expansionMode: 1, holdMs: 96.1256 })); + await expect(numeric).toBeDisabled(); + await page.evaluate(() => window.freeSuiteQA.update({ expansionMode: 0 })); + await expect(numeric).toBeEnabled(); + await expect(numeric).toHaveValue("96.126"); + expect(await page.evaluate(() => window.freeSuiteQA.writes.filter(write => write.id === "holdMs").length)).toBe(retained.writes); +}); + +test("untouched numeric fields preserve precision and external updates while deliberate edits stay reversible", async ({ page }) => { + for (const scenario of [ + { plugin: "compressor", id: "mix", name: "Parallel mix", initial: .123456789, external: .876543219, factor: 100, draft: "42.1256", edited: .421256 }, + { plugin: "gate", id: "threshold", name: "Open threshold", initial: -36.123456789, external: -12.987654321, factor: 1, draft: "-24.5678", edited: -24.5678 }, + ]) { + const editor = await mountFreeSuiteEditor(page, scenario.plugin, { [scenario.id]: scenario.initial }); + const numeric = editor.getByRole("spinbutton", { name: `${scenario.name} value`, exact: true }); + const slider = editor.getByRole("slider", { name: scenario.name, exact: true }); + await numeric.focus(); await numeric.evaluate(element => (element as HTMLInputElement).blur()); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.gestures.map(edit => edit.begin))).toEqual([true, false]); + await expect(editor.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); + expect(await page.evaluate(id => window.freeSuiteQA.schema.parameters.find(parameter => parameter.id === id)!.value, scenario.id)).toBe(scenario.initial); + await numeric.focus(); + await page.evaluate(({ id, external }) => window.freeSuiteQA.update({ [id]: external }), scenario); + await expect.poll(async () => Number(await slider.evaluate(element => element.getAttribute("aria-valuenow") ?? (element as HTMLInputElement).value))).toBeCloseTo(scenario.external, 2); + await numeric.evaluate(element => (element as HTMLInputElement).blur()); + await expect(numeric).toHaveValue(String(Number((scenario.external * scenario.factor).toFixed(3)))); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.gestures.map(edit => edit.begin))).toEqual([true, false, true, false]); + await expect(editor.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); + await page.evaluate(() => { window.freeSuiteQA.gestures = []; }); + await numeric.fill(scenario.draft); await numeric.press("Enter"); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.writes)).toEqual([{ id: scenario.id, value: expect.closeTo(scenario.edited, 12) }]); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.gestures.map(edit => edit.begin))).toEqual([true, false]); + await editor.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(numeric).toHaveValue(String(Number((scenario.external * scenario.factor).toFixed(3)))); + } +}); + +test("wide effects reveal useful secondary controls and retain compact collapse", async ({ page }) => { + await page.setViewportSize({ width: 3840, height: 2160 }); + const editor = await mountFreeSuiteEditor(page, "chorus"); + await expect(editor.getByRole("slider", { name: "High cut", exact: true })).toBeVisible(); + await expect(editor.getByRole("combobox", { name: "Character", exact: true })).toBeVisible(); + await page.setViewportSize({ width: 940, height: 520 }); + await expect(editor.getByRole("slider", { name: "High cut", exact: true })).toBeHidden(); + await editor.getByRole("button", { name: "Detail +", exact: true }).click(); + await editor.getByRole("button", { name: "Tone", exact: true }).click(); + await expect(editor.getByRole("slider", { name: "High cut", exact: true })).toBeVisible(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); +}); + +test("Find reaches the Reverb machine control at compact and wide sizes", async ({ page }) => { + for (const size of [{ width: 640, height: 480 }, { width: 1920, height: 1080 }]) { + await page.setViewportSize(size); + const editor = await mountFreeSuiteEditor(page, "reverb"); + for (const query of ["reverbTypeAll", "Reverb machines"]) { + await editor.getByRole("button", { name: "Find a control or feature" }).click(); + await page.getByRole("textbox", { name: "Search controls and features" }).fill(query); + await page.getByLabel("Search results").getByRole("button").first().click(); + await expect(page.getByRole("dialog")).toBeHidden(); + await expect(size.width === 640 ? editor.getByRole("combobox", { name: "Reverb machine", exact: true }) : editor.getByRole("searchbox", { name: "Search reverb machines" })).toBeFocused(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + } + } +}); + +test("Find explains disabled and inert settings with accessible focus and no writes", async ({ page }) => { + const scenarios: { plugin: string; values: Record; query: string }[] = [ + { plugin: "gate", values: { expansionMode: 1 }, query: "holdMs" }, + { plugin: "limiter", values: { limitingStyleAll: 0 }, query: "automaticRelease" }, + { plugin: "reverb", values: { sendMode: 1 }, query: "wetLevel" }, + ]; + for (const scenario of scenarios) { + const editor = await mountFreeSuiteEditor(page, scenario.plugin, scenario.values); + await editor.getByRole("button", { name: "Find a control or feature" }).click(); + await page.getByRole("textbox", { name: "Search controls and features" }).fill(scenario.query); + await page.getByLabel("Search results").getByRole("button").first().click(); + const notice = page.getByRole("dialog").getByRole("status"); + await expect(notice).toContainText("not active in the current configuration"); + await expect(notice).toBeFocused(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + } +}); diff --git a/frontend/e2e/free-suite-precision.spec.ts b/frontend/e2e/free-suite-precision.spec.ts new file mode 100644 index 0000000..1246ae8 --- /dev/null +++ b/frontend/e2e/free-suite-precision.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installFreeSuiteHarness, loadNativeFreeSuiteSchemas, mountFreeSuiteEditor, nativeFreeSuiteSchemasUnavailable } from "./support/freeSuiteHarness"; + +test.skip(nativeFreeSuiteSchemasUnavailable, "Export native editor schemas with tools/run-free-suite-ui-regression.ps1 first."); +test.beforeEach(async ({ page }) => installFreeSuiteHarness(page, loadNativeFreeSuiteSchemas())); +test.afterEach(async ({ page }) => page.evaluate(() => window.freeSuiteQA?.dispose())); + +async function findParameter(page: Page, id: string, inactive = false) { + await page.getByRole("button", { name: "Find a control or feature" }).click(); + await page.getByRole("textbox", { name: "Search controls and features" }).fill(id); + const result = page.getByRole("dialog").getByRole("button").filter({ has: page.locator("span", { hasText: new RegExp(`(?:^|[ ·])${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) }) }); + await result.first().click(); + if (inactive) { + const notice = page.getByRole("status").filter({ hasText: "not active in the current configuration" }); + await expect(notice).toBeFocused(); + await page.getByRole("dialog").getByRole("button", { name: "Close modal", exact: true }).click(); + } + await expect(page.getByRole("dialog")).toBeHidden(); +} + +test("precision editors fit compact, preferred and 4K hosts with useful fader travel", async ({ page }, testInfo) => { + for (const size of [{ width: 640, height: 480 }, { width: 1040, height: 680 }, { width: 3840, height: 2160 }]) { + await page.setViewportSize(size); + for (const id of ["eq", "geq", "utility", "pitch"]) { + const editor = await mountFreeSuiteEditor(page, id); + const outside = await editor.evaluate(root => Array.from(root.querySelectorAll("button,input,select,[role=slider]")) + .filter(element => element.getClientRects().length && getComputedStyle(element).visibility !== "hidden") + .filter(element => { const rect = element.getBoundingClientRect(); return rect.left < -1 || rect.right > innerWidth + 1; }) + .map(element => element.getAttribute("aria-label") || element.textContent)); + expect(outside, `${id} at ${size.width}`).toEqual([]); + if (id === "pitch") { + const output = await editor.locator('[aria-label="Realtime processing"]').boundingBox(); + expect(output!.y + output!.height).toBeLessThanOrEqual(size.height); + } + if (id === "geq" && size.width === 3840) { + const fader = await editor.locator(".graphic-eq-fader").first().boundingBox(); + expect(fader!.height).toBeLessThanOrEqual(500); + expect(fader!.height).toBeGreaterThan(180); + } + await page.screenshot({ path: testInfo.outputPath(`${id}-${size.width}.png`) }); + } + } +}); + +test("Pitch Find reveals source, formants and MIDI without altering saved settings", async ({ page }) => { + await page.setViewportSize({ width: 640, height: 480 }); + const editor = await mountFreeSuiteEditor(page, "pitch", { signalPath: 1, scale: 0, noteEnable_1: 0, midiOutputEnabled: 0, midiOutputChannel: 7 }); + const before = await page.evaluate(() => window.freeSuiteQA.schema.parameters); + for (const id of ["minFreqParam", "noteEnable_1", "formantShift", "midiOutputChannel"]) { + await findParameter(page, id, id === "noteEnable_1" || id === "midiOutputChannel"); + await expect(editor.locator(`[data-param="${id}"]`)).toBeVisible(); + } + await expect(editor.getByRole("slider", { name: "MIDI channel", exact: true })).toHaveAttribute("aria-disabled", "true"); + await expect(editor.getByRole("spinbutton", { name: "MIDI channel value" })).toHaveValue("7"); + await editor.getByRole("button", { name: "Source", exact: true }).click(); + await expect(editor.getByRole("button", { name: "C#", exact: true })).toBeDisabled(); + await expect(editor.getByRole("button", { name: "C#", exact: true })).toHaveAttribute("aria-pressed", "false"); + expect(await page.evaluate(() => window.freeSuiteQA.schema.parameters)).toEqual(before); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); +}); + +test("Pitch pages retain every live parameter and Custom switches keep absolute notes", async ({ page }) => { + const editor = await mountFreeSuiteEditor(page, "pitch", { signalPath: 1, scale: 15, key: 4 }); + const covered = new Set(); + for (const tab of ["Correction", "Source", "Formants", "MIDI output"]) { + await editor.getByRole("button", { name: tab, exact: true }).first().click(); + const visible = await editor.locator("[data-param]").evaluateAll(elements => elements.filter(element => element.getClientRects().length).map(element => element.getAttribute("data-param")!)); + visible.forEach(id => covered.add(id)); + } + const required = await page.evaluate(() => window.freeSuiteQA.schema.parameters.filter(p => p.id !== "signalPath" && p.type !== "meter").map(p => p.id)); + expect(required.filter(id => !covered.has(id))).toEqual([]); + await editor.getByRole("button", { name: "Source", exact: true }).click(); + await editor.getByRole("button", { name: "C#", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.writes[window.freeSuiteQA.writes.length - 1])).toEqual({ id: "noteEnable_1", value: 0 }); +}); + +test("Graphic EQ Reset selected changes only the active selected bank", async ({ page }) => { + const editor = await mountFreeSuiteEditor(page, "geq", { graphicMode: 0, geq0: 3, geq1: -4, third0: 6 }); + await editor.getByRole("button", { name: "Select 31 Hz", exact: true }).click(); + await editor.getByRole("button", { name: "Reset selected", exact: true }).click(); + await expect.poll(() => page.evaluate(() => Object.fromEntries(window.freeSuiteQA.schema.parameters.filter(p => ["geq0", "geq1", "third0"].includes(p.id)).map(p => [p.id, p.value])))).toEqual({ geq0: 0, geq1: -4, third0: 6 }); + await editor.getByRole("button", { name: "31 third-octave", exact: true }).click(); + await expect(editor.locator('[data-param="third0"] input[type=number]')).toHaveValue("6"); +}); + +test("Gain Phase Find opens spectral editing and all saved points remain reachable", async ({ page }) => { + await page.setViewportSize({ width: 640, height: 480 }); + const editor = await mountFreeSuiteEditor(page, "utility", { spectralPhaseEnabled: 0, spectralPhaseR47: 0.37 }); + await editor.getByRole("button", { name: "Find a control or feature" }).click(); + await page.getByRole("textbox", { name: "Search controls and features" }).fill("48 points"); + await page.getByRole("dialog").getByRole("button", { name: "Spectral phase 48 points per channel" }).click(); + await expect(page.getByRole("dialog")).toBeHidden(); + await expect(editor.getByRole("tab", { name: "Spectral", exact: true })).toHaveAttribute("aria-selected", "true"); + await editor.getByText("Edit saved phase points", { exact: true }).click(); + await editor.getByRole("combobox", { name: "Spectral phase frequency point" }).selectOption("47"); + await expect(editor.getByRole("combobox", { name: "Spectral phase frequency point" })).toHaveValue("47"); + await expect(editor.locator('[data-param="spectralPhaseR47"]')).toBeVisible(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + await editor.getByRole("tab", { name: "Alignment", exact: true }).click(); + await editor.getByText("Capture settings", { exact: false }).click(); + await expect(editor.getByRole("combobox", { name: "Alignment capture duration" })).toBeVisible(); + await expect(editor.getByRole("combobox", { name: "Alignment routing policy" })).toBeVisible(); +}); + +test("EQ Find reaches a late-band dynamics policy and existing phase settings", async ({ page }) => { + await page.setViewportSize({ width: 640, height: 480 }); + const editor = await mountFreeSuiteEditor(page, "eq"); + await findParameter(page, "band10.dynamicThresholdMode"); + await expect(editor.getByRole("combobox", { name: "Dynamic threshold mode" })).toBeVisible(); + await expect(editor.getByRole("combobox", { name: "Band selection" })).toHaveValue("10"); + await findParameter(page, "phaseMode"); + await expect(editor.getByRole("combobox", { name: "Phase mode" })).toBeFocused(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); +}); diff --git a/frontend/e2e/nam-fuzz-workflow.spec.ts b/frontend/e2e/nam-fuzz-workflow.spec.ts new file mode 100644 index 0000000..61c8386 --- /dev/null +++ b/frontend/e2e/nam-fuzz-workflow.spec.ts @@ -0,0 +1,171 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { nativeBridge as bridgeType } from "../src/services/NativeBridge"; +import { primaryModifier } from "./support/platformInput"; + +const address = { trackId: "nam-fuzz-workflow-qa", chain: "track", fxIndex: 0 } as const; +const url = `/?${new URLSearchParams({ window: "pluginEditor", platform: "windows", windowChrome: "native", mockPlugin: "nam", sessionId: JSON.stringify({ address, title: "OpenStudio NAM Rack", fallbackName: "OpenStudio NAM Rack" }), namView: "rack", namFocus: "gate", namSection: "pre" })}`; +type PresetEntries = Awaited>; +interface ProfileCopyQa { entries: PresetEntries; calls: [string, string, 0 | 1][]; fail: boolean } +declare global { interface Window { namProfileCopyQa: ProfileCopyQa } } + +async function openRack(page: Page) { + await page.goto(url); + await expect(page.getByRole("slider", { name: "Fuzz Clean blend", exact: true })).toBeVisible({ timeout: 15000 }); +} + +async function values(page: Page) { + return page.evaluate(async address => { + const path = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ path) as typeof import("../src/services/NativeBridge"); + return (await nativeBridge.getBuiltInPluginState(address))?.values; + }, address); +} + +test("Fuzz Clean pedal knob supports exact entry, cancel, keyboard, reset and Undo", async ({ page }) => { + await openRack(page); + const undoShortcut = `${await primaryModifier(page)}+z`; + const knob = page.getByRole("slider", { name: "Fuzz Clean blend", exact: true }); + await expect(knob).toHaveAccessibleDescription(/Fuzz is bypassed/); + await expect(knob).toHaveAccessibleDescription(/PRE EQ, Drive, Distortion/); + await knob.focus(); + await knob.press("Enter"); + const number = page.getByRole("spinbutton", { name: "Fuzz Clean blend percentage", exact: true }); + await number.fill("38.4"); + await number.press("Enter"); + await expect(knob).toBeFocused(); + await expect(knob).toHaveAttribute("aria-valuenow", "0.384"); + await expect.poll(async () => (await values(page))?.fuzzCleanMix).toBeCloseTo(0.384); + await knob.click({ button: "right" }); + await number.fill("75"); + await number.press("Escape"); + await expect(number).toHaveCount(0); + await expect(knob).toBeFocused(); + await expect(knob).toHaveAttribute("aria-valuenow", "0.384"); + await knob.press("Shift+ArrowRight"); + await expect(knob).toHaveAttribute("aria-valuenow", "0.386"); + await knob.dblclick(); + await expect(knob).toHaveAttribute("aria-valuenow", "0"); + await page.keyboard.press(undoShortcut); + await expect(knob).toHaveAttribute("aria-valuenow", "0.386"); + await page.keyboard.press(undoShortcut); + await expect(knob).toHaveAttribute("aria-valuenow", "0.384"); + await page.keyboard.press(undoShortcut); + await expect(knob).toHaveAttribute("aria-valuenow", "0"); +}); + +for (const viewport of [{ width: 640, height: 480 }, { width: 900, height: 600 }, { width: 1280, height: 900 }, { width: 1920, height: 1080 }, { width: 3840, height: 2160 }]) { + test(`Fuzz Clean is integrated without overlapping controls at ${viewport.width}x${viewport.height}`, async ({ page }, testInfo) => { + await page.setViewportSize(viewport); + await openRack(page); + const knob = page.getByRole("slider", { name: "Fuzz Clean blend", exact: true }); + await knob.scrollIntoViewIfNeeded(); + await expect(page.locator('[data-qa="nam-fuzz-clean-access"]')).toHaveCount(0); + await expect(page.locator('.octaver-fuzz [role="slider"]')).toHaveCount(7); + const geometry = await page.evaluate(() => { + const pedal = document.querySelector(".octaver-fuzz")!.getBoundingClientRect(); + const controls = [...document.querySelectorAll(".octaver-fuzz .control-hit")].map(control => { + const box = control.getBoundingClientRect(); + return { id: control.dataset.paramId, left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }; + }); + return { pedal: { left: pedal.left, top: pedal.top, right: pedal.right, bottom: pedal.bottom }, controls }; + }); + expect(geometry.controls.map(control => control.id)).toEqual(expect.arrayContaining([ + "fuzzCleanMix", "fuzzVoice", "octaverDownMix", "octaverUpMix", "octaverDirectMix", + "fuzzDrive", "fuzzTone", "fuzzLevelDb", "octaverEnabled", "fuzzEnabled", + ])); + for (const control of geometry.controls) { + expect(control.left).toBeGreaterThanOrEqual(geometry.pedal.left); + expect(control.top).toBeGreaterThanOrEqual(geometry.pedal.top); + expect(control.right).toBeLessThanOrEqual(geometry.pedal.right); + expect(control.bottom).toBeLessThanOrEqual(geometry.pedal.bottom); + expect(control.width).toBeGreaterThanOrEqual(24); + expect(control.height).toBeGreaterThanOrEqual(24); + for (const other of geometry.controls.filter(other => other.id !== control.id)) { + const overlaps = control.left < other.right && control.right > other.left + && control.top < other.bottom && control.bottom > other.top; + expect(overlaps, `${control.id} overlaps ${other.id}`).toBe(false); + } + } + await page.screenshot({ path: testInfo.outputPath(`fuzz-integrated-${viewport.width}.png`) }); + await knob.focus(); + await expect(knob).toBeFocused(); + await expect(knob).toHaveCSS("outline-style", "solid"); + await page.screenshot({ path: testInfo.outputPath(`fuzz-keyboard-focus-${viewport.width}.png`) }); + await knob.press("Enter"); + const number = page.getByRole("spinbutton", { name: "Fuzz Clean blend percentage", exact: true }); + await expect(number).toBeInViewport(); + const entry = await number.boundingBox(); + expect(entry!.x).toBeGreaterThanOrEqual(geometry.pedal.left); + expect(entry!.x + entry!.width).toBeLessThanOrEqual(geometry.pedal.right); + await number.press("Escape"); + await expect(knob).toBeFocused(); + for (const id of ["octaverEnabled", "fuzzEnabled"]) { + const foot = page.locator(`.octaver-fuzz [data-param-id="${id}"][role="button"]`); + await foot.focus(); + await expect(foot).toBeInViewport({ ratio: 0.999 }); + } + }); +} + +for (const width of [1280, 900, 640]) { +test(`profile review at ${width}px creates only an explicitly confirmed library copy and reports duplicate or failed copies`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: width === 640 ? 480 : 900 }); + await openRack(page); + if (width <= 900) { + await page.getByRole("button", { name: /^Guitar instrument profile/ }).click(); + await expect.poll(async () => (await values(page))?.instrumentProfile).toBe(1); + } + await page.evaluate(async () => { + const path = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ path) as typeof import("../src/services/NativeBridge"); + const qa: ProfileCopyQa = window.namProfileCopyQa = { entries: [{ name: "Historical Bass", path: "qa/Historical Bass.ospreset", instrumentProfile: "guitar", metadata: { instrumentProfile: "bass", updatedAt: Date.now() } }], calls: [], fail: false }; + nativeBridge.getBuiltInFXPresets = async () => structuredClone(qa.entries); + nativeBridge.copyNAMRackPresetWithInstrumentProfile = async (source, target, profile) => { + qa.calls.push([source, target, profile]); + if (qa.fail) return false; + qa.entries.push({ name: target, path: `qa/${target}.ospreset`, instrumentProfile: profile ? "bass" : "guitar", metadata: { instrumentProfile: profile ? "bass" : "guitar", updatedAt: Date.now() } }); + return true; + }; + }); + const before = await values(page); + await page.getByRole("button", { name: /Open preset library/ }).click(); + const library = page.getByRole("dialog", { name: "Preset Library", exact: true }); + await library.getByRole("button", { name: "Refresh", exact: true }).click(); + await library.getByRole("button", { name: /Profile review/ }).click(); + await expect(library.getByRole("button", { name: /Historical Bass/ }).first()).toContainText("Label: Bass · Saved sound: Guitar"); + await expect(library.locator('[data-modal-panel="true"]')).toHaveCSS("opacity", "1"); + await page.screenshot({ path: testInfo.outputPath(`profile-review-${width}.png`) }); + const openCopy = async () => { + if (width <= 900) { + await library.getByRole("button", { name: "Actions for Historical Bass", exact: true }).click(); + await page.getByRole("menuitem", { name: "Create Bass copy…", exact: true }).click(); + } else { + await library.getByRole("button", { name: "Create Bass copy…", exact: true }).click(); + } + }; + const confirm = page.getByRole("dialog", { name: "Create Bass copy", exact: true }); + await openCopy(); + await expect(confirm).toContainText("does not load it or change the original"); + await expect(confirm.locator('[data-modal-panel="true"]')).toHaveCSS("opacity", "1"); + await page.screenshot({ path: testInfo.outputPath(`profile-confirm-${width}.png`) }); + await confirm.getByRole("button", { name: "Cancel", exact: true }).click(); + expect(await page.evaluate(() => window.namProfileCopyQa.calls)).toEqual([]); + await openCopy(); + await confirm.getByRole("textbox", { name: "Create Bass copy" }).fill("Historical Bass"); + await confirm.getByRole("button", { name: "Create copy", exact: true }).click(); + await expect(library.getByRole("status")).toContainText("already exists"); + expect(await page.evaluate(() => window.namProfileCopyQa.calls)).toEqual([]); + await page.evaluate(() => { window.namProfileCopyQa.fail = true; }); + await openCopy(); + await confirm.getByRole("button", { name: "Create copy", exact: true }).click(); + await expect(library.getByRole("status")).toContainText("could not be created"); + await page.evaluate(() => { window.namProfileCopyQa.fail = false; }); + await openCopy(); + await confirm.getByRole("textbox", { name: "Create Bass copy" }).fill("Bass Audition"); + await confirm.getByRole("button", { name: "Create copy", exact: true }).click(); + await expect(library.getByRole("status")).toContainText("Created Bass Audition with the Bass profile"); + expect(await values(page)).toEqual(before); + expect(await page.evaluate(() => window.namProfileCopyQa.entries.map(({ name, instrumentProfile }) => ({ name, instrumentProfile })))).toEqual([{ name: "Historical Bass", instrumentProfile: "guitar" }, { name: "Bass Audition", instrumentProfile: "bass" }]); + expect(await page.evaluate(() => window.namProfileCopyQa.calls)).toEqual([["Historical Bass", "Historical Bass Bass Copy", 1], ["Historical Bass", "Bass Audition", 1]]); +}); +} diff --git a/frontend/e2e/nam-rack-approved-surfaces.spec.ts b/frontend/e2e/nam-rack-approved-surfaces.spec.ts index 16dd8a2..57934bd 100644 --- a/frontend/e2e/nam-rack-approved-surfaces.spec.ts +++ b/frontend/e2e/nam-rack-approved-surfaces.spec.ts @@ -893,11 +893,11 @@ test("PRE row keeps five sibling pedals visible without horizontal scrolling", a 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 }, + compressor: { x: 67, y: 42, w: 156, h: 232 }, + octaver: { x: 233, y: 42, w: 156, h: 232 }, + "eq-boost": { x: 399, y: 42, w: 156, h: 232 }, + "precision-drive": { x: 565, y: 42, w: 120, h: 232 }, + distortion: { x: 695, y: 42, w: 156, h: 232 }, } as const; const metrics = await host.evaluate((root, expected) => { diff --git a/frontend/e2e/recording-zoom.spec.ts b/frontend/e2e/recording-zoom.spec.ts new file mode 100644 index 0000000..cfa3626 --- /dev/null +++ b/frontend/e2e/recording-zoom.spec.ts @@ -0,0 +1,448 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { createDefaultTrack, useDAWStore } from "../src/store/useDAWStore"; +import type { nativeBridge } from "../src/services/NativeBridge"; +import type {} from "./support/interactionFixtureTypes"; +import { primaryModifier } from "./support/platformInput"; + +interface PeakRequest { + trackId: string; + samplesPerPixel: number; + numPixels: number; + startSample: number; + zoom: number; + scrollX: number; + completed: boolean; +} +interface RecordingZoomFixture { + store: typeof useDAWStore; + requests: PeakRequest[]; + hold: boolean; + pending: number; + maxPending: number; + timeUpdates: number; + anchorX: number | null; + press: { zoom: number; scrollX: number; time: number } | null; + publications: { zoom: number; scrollX: number }[]; + release: () => void; + cleanup: () => void; +} +declare global { + interface Window { + __recordingZoomIntegration: RecordingZoomFixture; + __recordingZoomModules: { + store: typeof useDAWStore; + createDefaultTrack: typeof createDefaultTrack; + nativeBridge: typeof nativeBridge; + }; + } +} + +const SAMPLE_RATE = 44100; +const TRACK_COLORS = ["#4361ee", "#4cc9f0"]; +const paint = (page: Page) => page.evaluate(() => new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); +})); + +async function ready(page: Page) { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.addInitScript(() => { + localStorage.setItem("openstudio.inputProfiles.v1", JSON.stringify({ + schemaVersion: 1, keyboardProfileId: "openstudio", mouseProfileId: "openstudio", onboardingSeen: true, + })); + localStorage.setItem("openstudio_essentialControlsDismissed", "true"); + }); + await page.goto("/?platform=windows&windowChrome=native"); + await expect(page.getByText("Starting OpenStudio...", { exact: true })).toBeHidden(); + await expect(page.getByRole("contentinfo", { name: "Transport controls" })).toBeVisible(); + // Use App's exact module URLs so the fixture shares its store/bridge. Load + // them through a document-owned module script: dynamic import inside a CDP + // evaluation can lose its pending Promise to Chromium garbage collection. + const appResponse = await page.request.get("/src/App.tsx"); + expect(appResponse.ok()).toBe(true); + const source = await appResponse.text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + const bridgeUrl = source.match(/from "([^"]*NativeBridge\.ts[^"]*)"/)?.[1]; + if (!storeUrl || !bridgeUrl) throw new Error("App store/bridge imports were not found"); + await page.addScriptTag({ type: "module", content: ` + import { useDAWStore as store, createDefaultTrack } from ${JSON.stringify(storeUrl)}; + import { nativeBridge } from ${JSON.stringify(bridgeUrl)}; + window.__recordingZoomModules = { store, createDefaultTrack, nativeBridge }; + ` }); + await page.waitForFunction(() => Boolean(window.__recordingZoomModules)); + await page.evaluate(() => { + const { store, createDefaultTrack, nativeBridge } = window.__recordingZoomModules; + const originalPeaks = nativeBridge.getRecordingPeaks; + let releasePending: (() => void) | undefined; + let serial = Promise.resolve(); + const fixture: RecordingZoomFixture = { + store, requests: [], hold: false, pending: 0, maxPending: 0, timeUpdates: 0, + anchorX: null, press: null, publications: [], release: () => releasePending?.(), cleanup: () => {}, + }; + window.__recordingZoomIntegration = fixture; + nativeBridge.getRecordingPeaks = (trackId, samplesPerPixel, numPixels, startSample = 0) => { + const state = store.getState(); + const clipStart = state.recordingClips.find(clip => clip.trackId === trackId)?.startTime ?? 0; + const availableSamples = Math.floor((state.transport.currentTime - clipStart) * 44100); + const request: PeakRequest = { + trackId, samplesPerPixel, numPixels, startSample, zoom: state.pixelsPerSecond, + scrollX: state.scrollX, completed: false, + }; + fixture.requests.push(request); + fixture.maxPending = Math.max(fixture.maxPending, ++fixture.pending); + // Model a serial native boundary. A response already in progress may + // contain valid old samples even after the UI has stopped recording. + const response = serial.then(async () => { + if (fixture.hold) await new Promise(resolve => { + releasePending = () => { releasePending = undefined; resolve(); }; + }); + const count = Math.max(0, Math.min(numPixels, 8192, + Math.floor((availableSamples - startSample) / samplesPerPixel))); + const peaks = Array.from({ length: count }, (_, index) => { + const amplitude = 0.25 + ((Math.floor(startSample / samplesPerPixel) + index) % 8) * 0.025; + return { channels: [{ min: -amplitude, max: amplitude }] }; + }); + request.completed = true; + fixture.pending -= 1; + return peaks; + }); + serial = response.then(() => undefined); + return response; + }; + const unsubscribe = store.subscribe((state, previous) => { + if (state.transport.currentTime !== previous.transport.currentTime) fixture.timeUpdates += 1; + if (fixture.anchorX !== null && (state.pixelsPerSecond !== previous.pixelsPerSecond || state.scrollX !== previous.scrollX)) + fixture.publications.push({ zoom: state.pixelsPerSecond, scrollX: state.scrollX }); + }); + // Observe the actual press-to-release interval, including queued follow work + // before vertical intent locks. Driver delays outside it are unrelated. + const observePress = (event: MouseEvent) => { + const canvas = event.target; + if (event.button !== 0 || !(canvas instanceof HTMLCanvasElement) + || !canvas.closest(".workspace-sticky-ruler")) return; + const state = store.getState(); + fixture.press = { zoom: state.pixelsPerSecond, scrollX: state.scrollX, time: state.transport.currentTime }; + fixture.anchorX = event.clientX - canvas.getBoundingClientRect().left; + fixture.publications = []; + }; + const finishObservation = () => { fixture.anchorX = null; }; + window.addEventListener("mousedown", observePress, true); + // Production window handlers are already registered and flush the final + // transform before these listeners end observation. + window.addEventListener("mouseup", finishObservation); + window.addEventListener("pointercancel", finishObservation); + window.addEventListener("blur", finishObservation); + fixture.cleanup = () => { + window.removeEventListener("mousedown", observePress, true); + window.removeEventListener("mouseup", finishObservation); + window.removeEventListener("pointercancel", finishObservation); + window.removeEventListener("blur", finishObservation); + store.setState({ recordingClips: [], recordSession: null, + transport: { ...store.getState().transport, isPlaying: false, isRecording: false } }); + fixture.hold = false; + fixture.release(); + unsubscribe(); + nativeBridge.getRecordingPeaks = originalPeaks; + }; + const first = createDefaultTrack("record-zoom-a", "Recording A", "#4361ee", "audio", []); + const second = createDefaultTrack("record-zoom-b", "Recording B", "#4cc9f0", "audio", [first]); + first.armed = second.armed = true; + store.setState({ + tracks: [first, second], selectedTrackId: first.id, selectedTrackIds: [first.id], + selectedClipIds: [], selectedClipId: null, showMixer: false, showGettingStarted: false, + showPianoRoll: false, showPitchEditor: false, showPreferences: false, showSettings: false, + inputProfileOnboardingSeen: true, keyboardShortcutProfileId: "openstudio", customShortcuts: {}, + pixelsPerSecond: 100, scrollX: 55000, scrollY: 0, snapEnabled: false, autoBackupEnabled: false, + autoScrollDuringPlayback: true, playheadStopBehavior: "stop-in-place", + mouseBehaviorProfileId: "openstudio", mouseModifiers: {}, timeSelection: null, + projectRange: { start: 0, end: 600 }, + recordingClips: [{ trackId: first.id, startTime: 0 }, { trackId: second.id, startTime: 0 }], + recordSession: { id: "recording-zoom-regression", startTime: 0, trackIds: [first.id, second.id] }, + transport: { ...store.getState().transport, currentTime: 556, isPlaying: true, isRecording: true, loopEnabled: false }, + }); + (document.activeElement as HTMLElement | null)?.blur(); + }); + await expect.poll(() => geometry(page).then(value => value.lines.length)).toBe(2); +} + +async function geometry(page: Page) { + return page.evaluate(() => { + const state = window.__recordingZoomIntegration.store.getState(); + const stage = window.Konva.stages.find(candidate => candidate.height() > 50 && candidate.container().closest(".workspace")); + const lines = stage?.find("Line").filter(node => node instanceof window.Konva.Line + && node.closed() && node.opacity() === 0.8 && node.strokeWidth() === 0.5) ?? []; + return { + zoom: state.pixelsPerSecond, scrollX: state.scrollX, time: state.transport.currentTime, + recording: state.transport.isRecording, range: state.projectRange, + width: stage?.width() ?? 0, + lines: lines.map(node => { + const line = node as InstanceType; + const points = line.points(); + return { color: line.stroke(), firstX: points[0], secondX: points[2], pointCount: points.length }; + }), + }; + }); +} + +async function holdNextRequest(page: Page) { + await page.evaluate(() => { window.__recordingZoomIntegration.hold = true; }); + await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.pending)).toBe(1); + return page.evaluate(() => window.__recordingZoomIntegration.requests.length); +} + +async function dragRuler(page: Page, dy: number, dx = 0) { + const bounds = await page.locator(".workspace-sticky-ruler canvas").first().boundingBox(); + if (!bounds) throw new Error("Visible workspace ruler is missing"); + const x = Math.min(350, bounds.width / 2); + await page.mouse.move(bounds.x + x, bounds.y + 18); + await page.mouse.down(); + for (let step = 1; step <= 6; step += 1) { + await page.mouse.move(bounds.x + x + dx * step / 6, bounds.y + 18 + dy * step / 6); + await paint(page); + } + await page.mouse.up(); + await paint(page); + const { before, publications } = await page.evaluate(() => { + const fixture = window.__recordingZoomIntegration; + if (!fixture.press) throw new Error("Ruler press was not observed"); + return { before: fixture.press, publications: fixture.publications }; + }); + return { before, after: await geometry(page), publications, anchorX: x }; +} + +test.afterEach(async ({ page }) => { + await page.evaluate(() => window.__recordingZoomIntegration?.cleanup()); +}); + +test("long two-track recording keeps ruler zoom bounded, anchored and sample-aligned", async ({ page }, testInfo) => { + await ready(page); + const callsAtHold = await holdNextRequest(page); + const baseline = await geometry(page); + const baselineRequests = await page.evaluate(() => window.__recordingZoomIntegration.requests.filter(request => request.completed)); + // The large downward gesture also pushes the live playhead beyond the usual + // follow threshold; auto-follow must leave the active zoom anchor alone. + for (const dy of [-36, 110, -24]) { + const drag = await dragRuler(page, dy); + expect(dy > 0 ? drag.after.zoom > drag.before.zoom : drag.after.zoom < drag.before.zoom).toBe(true); + expect(drag.after.range).toEqual(baseline.range); + expect(drag.publications.length).toBeGreaterThan(0); + for (const publication of drag.publications) + expect((publication.scrollX + drag.anchorX) / publication.zoom) + .toBeCloseTo((drag.before.scrollX + drag.anchorX) / drag.before.zoom, 6); + // Cached samples may be hidden while refreshing, but must never retain + // pixel geometry from a previous zoom when they remain visible. + for (const line of drag.after.lines) { + const trackId = line.color === TRACK_COLORS[0] ? "record-zoom-a" : "record-zoom-b"; + const previous = [...baselineRequests].reverse().find(request => request.trackId === trackId); + expect(previous).toBeDefined(); + const expectedSpacing = previous!.samplesPerPixel / SAMPLE_RATE * drag.after.zoom; + expect(line.secondX - line.firstX).toBeCloseTo(expectedSpacing, 5); + const bin = (line.firstX + drag.after.scrollX) / expectedSpacing; + expect(bin).toBeCloseTo(Math.round(bin), 4); + expect(line.pointCount).toBeLessThan((drag.after.width + 256) * 8); + } + } + const beforeScroll = await geometry(page); + await page.locator(".timeline-container").hover(); + await page.mouse.wheel(280, 0); + await paint(page); + await expect.poll(() => geometry(page).then(value => value.scrollX)).toBeGreaterThan(beforeScroll.scrollX); + await expect.poll(() => geometry(page).then(value => value.time)).toBeGreaterThan(baseline.time); + const pending = await page.evaluate(() => ({ + count: window.__recordingZoomIntegration.requests.length, + maximum: window.__recordingZoomIntegration.maxPending, + updates: window.__recordingZoomIntegration.timeUpdates, + })); + expect(pending.count).toBe(callsAtHold); + expect(pending.maximum).toBe(1); + expect(pending.updates).toBeGreaterThan(0); + await page.evaluate(() => { const fixture = window.__recordingZoomIntegration; fixture.hold = false; fixture.release(); }); + await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBeGreaterThan(callsAtHold); + const requests = await page.evaluate(() => window.__recordingZoomIntegration.requests); + for (const request of requests) { + expect(request.startSample).toBeGreaterThan(0); + expect(request.numPixels).toBeLessThanOrEqual(8192); + expect(request.numPixels * request.samplesPerPixel / SAMPLE_RATE * request.zoom).toBeLessThan(baseline.width * 4 + 512); + } + await testInfo.attach("recording-zoom-boundary", { body: JSON.stringify({ + scope: "Browser interaction with a controlled serial peak service; native timing/audio are not asserted.", + seededSeconds: 556, pending, requests, + }, null, 2), contentType: "application/json" }); +}); + +test("Stop retires pending recording peaks without issuing the old next-track request", async ({ page }) => { + await ready(page); + const callsAtHold = await holdNextRequest(page); + await dragRuler(page, 35); + await page.getByRole("contentinfo", { name: "Transport controls" }) + .getByRole("button", { name: "Stop", exact: true }).click(); + await expect.poll(() => geometry(page).then(value => value.recording)).toBe(false); + await expect.poll(() => geometry(page).then(value => value.lines.length)).toBe(0); + await page.evaluate(() => { const fixture = window.__recordingZoomIntegration; fixture.hold = false; fixture.release(); }); + await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.pending)).toBe(0); + await paint(page); + expect(await page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBe(callsAtHold); + expect((await geometry(page)).lines).toEqual([]); +}); + +test("horizontal ruler range selection remains usable while live peak service is pending", async ({ page }) => { + await ready(page); + const callsAtHold = await holdNextRequest(page); + const drag = await dragRuler(page, 0, 90); + expect(drag.after.zoom).toBe(drag.before.zoom); + expect(drag.after.range.start).toBeCloseTo((drag.before.scrollX + drag.anchorX) / drag.before.zoom, 6); + expect(drag.after.range.end).toBeCloseTo((drag.before.scrollX + drag.anchorX + 90) / drag.before.zoom, 6); + expect(await page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBe(callsAtHold); + await expect.poll(() => geometry(page).then(value => value.time)).toBeGreaterThan(drag.before.time); +}); + +test("recording respects the automatic-scroll preference", async ({ page }) => { + await ready(page); + const before = await geometry(page); + await page.evaluate(() => window.__recordingZoomIntegration.store.setState({ + autoScrollDuringPlayback: false, scrollX: 1000, + })); + await expect.poll(() => geometry(page).then(value => value.time)).toBeGreaterThan(before.time); + await paint(page); + expect((await geometry(page)).scrollX).toBe(1000); + await page.evaluate(() => window.__recordingZoomIntegration.store.setState({ autoScrollDuringPlayback: true })); + await expect.poll(() => geometry(page).then(value => value.scrollX)).toBeGreaterThan(1000); +}); + +for (const [surface, selector] of [ + ["sticky ruler", ".workspace-sticky-ruler canvas"], + ["timeline body", ".timeline-container canvas"], +] as const) { + test(`${surface} preserves zoom then pan when wheel modifiers change before a frame`, async ({ page }) => { + await ready(page); + await page.evaluate(() => window.__recordingZoomIntegration.store.setState({ autoScrollDuringPlayback: false })); + await paint(page); + const before = await geometry(page); + const anchorX = 350; + const panPixels = 96; + const zoomModifier = await primaryModifier(page); + // One synchronous browser task guarantees no animation frame can separate + // these packets; real wheel handlers still resolve modifiers and route them. + const prevented = await page.locator(selector).first().evaluate((element, { anchorX, panPixels, zoomModifier }) => { + const bounds = element.getBoundingClientRect(); + const common = { bubbles: true, cancelable: true, deltaMode: 0, + clientX: bounds.left + anchorX, clientY: bounds.top + 18 }; + const zoom = new WheelEvent("wheel", { ...common, deltaY: -80, + ctrlKey: zoomModifier === "Control", metaKey: zoomModifier === "Meta" }); + const pan = new WheelEvent("wheel", { ...common, deltaX: panPixels }); + element.dispatchEvent(zoom); + element.dispatchEvent(pan); + return { zoom: zoom.defaultPrevented, pan: pan.defaultPrevented }; + }, { anchorX, panPixels, zoomModifier }); + await paint(page); + const after = await geometry(page); + expect(prevented).toEqual({ zoom: true, pan: true }); + expect(after.zoom).toBeGreaterThan(before.zoom); + const anchoredScroll = (before.scrollX + anchorX) / before.zoom * after.zoom - anchorX; + expect(after.scrollX).toBeCloseTo(anchoredScroll + panPixels, 6); + expect(after.range).toEqual(before.range); + }); +} + +for (const termination of ["vertical zoom", "mouseup", "pointercancel", "blur"] as const) { + test(`pending ruler press protects its anchor and recovers after ${termination}`, async ({ page }) => { + await ready(page); + const callsAtHold = await holdNextRequest(page); + const canvas = page.locator(".workspace-sticky-ruler canvas").first(); + const bounds = await canvas.boundingBox(); + if (!bounds) throw new Error("Visible workspace ruler is missing"); + const anchorX = Math.min(350, bounds.width / 2); + await page.mouse.move(bounds.x + anchorX, bounds.y + 18); + // Queue real automatic follow in the same input task, before the ruler + // claims the press. Its pending RAF must not overwrite the pressed anchor. + await canvas.evaluate(element => element.addEventListener("mousedown", () => { + const store = window.__recordingZoomIntegration.store; + store.getState().setCurrentTime(store.getState().transport.currentTime + 15); + }, { capture: true, once: true })); + await page.mouse.down(); + const pressed = await geometry(page); + const press = await page.evaluate(() => window.__recordingZoomIntegration.press!); + expect(pressed.time * press.zoom - press.scrollX).toBeGreaterThan(pressed.width); + await page.evaluate(() => { + const fixture = window.__recordingZoomIntegration; + fixture.hold = false; + fixture.release(); + }); + await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.pending)).toBe(0); + // Wait for advancing transport, past the ordinary 200ms zoom idle period. + await expect.poll(() => geometry(page).then(value => value.time)).toBeGreaterThan(pressed.time + 0.45); + const pending = await geometry(page); + expect(pending.zoom).toBe(press.zoom); + expect(pending.scrollX).toBeCloseTo(press.scrollX, 6); + expect(await page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBe(callsAtHold); + + if (termination === "vertical zoom") { + await page.mouse.move(bounds.x + anchorX, bounds.y + 108); + await paint(page); + const zoomed = await geometry(page); + expect(zoomed.zoom).toBeGreaterThan(press.zoom); + expect((zoomed.scrollX + anchorX) / zoomed.zoom) + .toBeCloseTo((press.scrollX + anchorX) / press.zoom, 6); + const publications = await page.evaluate(() => window.__recordingZoomIntegration.publications); + expect(publications.length).toBeGreaterThan(0); + for (const publication of publications) + expect((publication.scrollX + anchorX) / publication.zoom) + .toBeCloseTo((press.scrollX + anchorX) / press.zoom, 6); + } else if (termination !== "mouseup") { + await page.evaluate(event => window.dispatchEvent(new Event(event)), termination); + } + await page.mouse.up(); + expect(await page.evaluate(() => window.__recordingZoomIntegration.store.getState().isTimelineRulerZooming)).toBe(false); + const released = await geometry(page); + // A click can seek inside the viewport; move transport past its right edge + // to verify that follow and polling recover after every termination path. + await page.evaluate(() => { + const store = window.__recordingZoomIntegration.store; + store.getState().setCurrentTime(590); + }); + await expect.poll(() => geometry(page).then(value => value.scrollX)).toBeGreaterThan(released.scrollX); + await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBeGreaterThan(callsAtHold); + expect((await geometry(page)).range).toEqual(pressed.range); + }); +} + +for (const termination of ["mouseup", "pointercancel", "blur"] as const) { + test(`held ruler zoom pauses follow and preview polling until ${termination}`, async ({ page }) => { + await ready(page); + const callsAtHold = await holdNextRequest(page); + const bounds = await page.locator(".workspace-sticky-ruler canvas").first().boundingBox(); + if (!bounds) throw new Error("Visible workspace ruler is missing"); + const anchorX = Math.min(350, bounds.width / 2); + const rangeBefore = (await geometry(page)).range; + await page.mouse.move(bounds.x + anchorX, bounds.y + 18); + await page.mouse.down(); + const before = await page.evaluate(() => window.__recordingZoomIntegration.press!); + await page.mouse.move(bounds.x + anchorX, bounds.y + 118); + await paint(page); + const held = await geometry(page); + expect(held.zoom).toBeGreaterThan(before.zoom); + // Ensure follow would actually trigger if the held gesture were forgotten. + expect(held.time * held.zoom - held.scrollX).toBeGreaterThan(held.width); + await page.evaluate(() => { + const fixture = window.__recordingZoomIntegration; + fixture.hold = false; + fixture.release(); + }); + await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.pending)).toBe(0); + await expect.poll(() => geometry(page).then(value => value.time)).toBeGreaterThan(held.time + 0.45); + const paused = await geometry(page); + expect(paused.zoom).toBe(held.zoom); + expect(paused.scrollX).toBeCloseTo(held.scrollX, 6); + expect((paused.scrollX + anchorX) / paused.zoom) + .toBeCloseTo((before.scrollX + anchorX) / before.zoom, 6); + expect(await page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBe(callsAtHold); + + if (termination === "mouseup") await page.mouse.up(); + else { + await page.evaluate(event => window.dispatchEvent(new Event(event)), termination); + await page.mouse.up(); + } + await expect.poll(() => geometry(page).then(value => value.scrollX)).toBeGreaterThan(held.scrollX); + await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBeGreaterThan(callsAtHold); + expect((await geometry(page)).range).toEqual(rangeBefore); + }); +} diff --git a/frontend/e2e/release-review-ui.spec.ts b/frontend/e2e/release-review-ui.spec.ts new file mode 100644 index 0000000..9f146e6 --- /dev/null +++ b/frontend/e2e/release-review-ui.spec.ts @@ -0,0 +1,149 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installFreeSuiteHarness, loadNativeFreeSuiteSchemas, mountFreeSuiteEditor, nativeFreeSuiteSchemasUnavailable } from "./support/freeSuiteHarness"; + +type NAMReleaseQA = { + frames: number; + blocks: number; + emergency: boolean; + acceptSave: boolean; + writes: { path: string; document: Record }[]; + projectWrites: number; +}; +declare global { interface Window { namReleaseQA: NAMReleaseQA } } + +async function openNAM(page: Page, manager = false) { + const session = { address: { trackId: "nam-release-review", chain: "track", fxIndex: 0 }, title: "OpenStudio NAM Rack", fallbackName: "OpenStudio NAM Rack" }; + const query = new URLSearchParams({ window: "pluginEditor", platform: "windows", windowChrome: "native", mockPlugin: "nam", sessionId: JSON.stringify(session), namView: "rack", namSection: "post", namFocus: "reverb", namPresetManager: manager ? "1" : "0" }); + await page.goto(`/?${query}`); + await page.locator(".nam-rack-design-port.nam-native-design-surface").first().waitFor(); + // Static module import keeps initialization rooted across Vite dependency loads. + const source = await (await page.request.get("/src/components/NAMRackPanel.tsx")).text(); + const bridgeUrl = source.match(/from "([^"]*NativeBridge\.ts[^"]*)"/)?.[1]; + if (!bridgeUrl) throw new Error("NAM bridge module was not found"); + await page.addScriptTag({ type: "module", content: ` + import { nativeBridge } from ${JSON.stringify(bridgeUrl)}; + const state = window.namReleaseQA = { frames: 18911, blocks: 20000, emergency: false, acceptSave: false, writes: [], projectWrites: 0 }; + nativeBridge.getNAMRackDiagnostics = async () => ({ + processedBlockCount: state.blocks, rackOutputSafetyGuardHitCount: state.frames, + rackOutputEmergencyMuteActive: state.emergency, rackOutputEmergencyMuteTripCount: 0, + lastRawInputPeakDb: -90, lastInputPeakDb: -90, lastOutputPeakDb: -90 + }); + nativeBridge.saveJsonDocumentToFile = async (path, json) => { + state.writes.push({ path, document: JSON.parse(json) }); return state.acceptSave; + }; + nativeBridge.saveProjectToFile = async () => { state.projectWrites++; throw new Error("Preset reached project-only writer"); }; + nativeBridge.showSaveDialog = async () => "C:/review/Tele Clean.osnampreset"; + ` }); + await page.waitForFunction(() => Boolean(window.namReleaseQA)); +} + +test("NAM output protection distinguishes recent reductions from history and keeps complete guidance readable", async ({ page }, testInfo) => { + await openNAM(page); + const status = page.locator(".premium-stage-status"); + await expect(status).toContainText("Earlier output peaks were reduced"); + await expect(status).toHaveAttribute("data-tone", "info"); + await page.evaluate(() => { window.namReleaseQA.frames++; window.namReleaseQA.blocks++; }); + await expect(status).toContainText("Output peaks reduced recently"); + await expect(status).toHaveAttribute("data-tone", "warning"); + await expect(status).toContainText("Earlier output peaks were reduced", { timeout: 6000 }); + await expect(status).toHaveAttribute("data-tone", "info"); + + for (const size of [{ width: 640, height: 480 }, { width: 1280, height: 800 }, { width: 1920, height: 1080 }, { width: 3840, height: 2160 }]) { + await page.setViewportSize(size); + await status.focus(); + await expect(status).toBeFocused(); + const geometry = await status.evaluate(element => { + const bounds = element.getBoundingClientRect(), text = element.querySelector("span")!; + const style = getComputedStyle(text), focus = getComputedStyle(element); + return { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom, + clipped: text.scrollWidth > text.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1, + textOverflow: style.textOverflow, whiteSpace: style.whiteSpace, fontSize: parseFloat(style.fontSize), + focusWidth: parseFloat(focus.outlineWidth), role: element.getAttribute("role") }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); expect(geometry.right).toBeLessThanOrEqual(size.width); + expect(geometry.top).toBeGreaterThanOrEqual(0); expect(geometry.bottom).toBeLessThanOrEqual(size.height); + expect(geometry.clipped).toBe(false); expect(geometry.textOverflow).not.toBe("ellipsis"); + expect(geometry.whiteSpace).not.toBe("nowrap"); expect(geometry.fontSize).toBeGreaterThanOrEqual(12); + expect(geometry.focusWidth).toBeGreaterThan(0); expect(geometry.role).toBe("status"); + await page.screenshot({ path: testInfo.outputPath(`nam-protection-${size.width}.png`) }); + } + await page.evaluate(() => { window.namReleaseQA.emergency = true; }); + await expect(status).toContainText("NAM Rack safety mute is active"); + await expect(status).toHaveAttribute("data-tone", "error"); + await page.evaluate(() => { window.namReleaseQA.emergency = false; window.namReleaseQA.frames = 0; window.namReleaseQA.blocks = 0; }); + await expect(status).not.toContainText("output peaks were reduced"); +}); + +test("NAM metadata rejects failed disk saves without updating its cache, and portable export uses the JSON writer", async ({ page }) => { + await openNAM(page, true); + const manager = page.getByRole("dialog", { name: /Preset/ }); + const before = await page.evaluate(() => localStorage.getItem("openstudio_nam_rack_preset_metadata")); + const favorite = async () => { + await page.getByRole("button", { name: "Actions for Tele Clean Saved", exact: true }).click(); + await page.getByRole("menuitem", { name: "Favorite", exact: true }).click(); + }; + await favorite(); + await expect(manager).toContainText("Preset details could not be saved"); + expect(await page.evaluate(() => localStorage.getItem("openstudio_nam_rack_preset_metadata"))).toBe(before); + expect(await page.evaluate(() => window.namReleaseQA.writes)).toHaveLength(1); + await page.evaluate(() => { window.namReleaseQA.acceptSave = true; }); + await favorite(); + await expect(manager).not.toContainText("Preset details could not be saved"); + await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("openstudio_nam_rack_preset_metadata") || "{}")["Tele Clean Saved"]?.favorite)).toBe(true); + const metadata = await page.evaluate(() => window.namReleaseQA.writes[1]); + expect(metadata.path).toContain(".metadata.json"); expect(metadata.document.kind).toBe("openstudio.namRackPresetMetadata"); + expect(metadata.document).not.toHaveProperty("tracks"); + + await page.evaluate(() => { window.namReleaseQA.acceptSave = false; }); + await page.getByRole("button", { name: "Actions for Tele Clean Saved", exact: true }).click(); + await page.getByRole("menuitem", { name: "Export", exact: true }).click(); + await expect(manager).toContainText("Preset could not be exported"); + expect(await page.evaluate(() => window.namReleaseQA.projectWrites)).toBe(0); + const portable = await page.evaluate(() => window.namReleaseQA.writes[window.namReleaseQA.writes.length - 1]); + expect(portable.document.kind).toBe("openstudio.namRackPreset"); expect(portable.document).toHaveProperty("state"); + expect(portable.document).not.toHaveProperty("tracks"); +}); + +test("InstrumentSlider preserves untouched precision, commits once, cancels Escape and follows external updates", async ({ page }) => { + test.skip(nativeFreeSuiteSchemasUnavailable, "Native editor schemas required for instrument qualification"); + await page.setViewportSize({ width: 640, height: 480 }); + await installFreeSuiteHarness(page, loadNativeFreeSuiteSchemas()); + const editor = await mountFreeSuiteEditor(page, "synth", { macro1: .1234567 }); + await editor.getByRole("button", { name: "Modulation", exact: true }).click(); + await editor.getByRole("button", { name: "Macros", exact: true }).click(); + const input = editor.getByRole("spinbutton", { name: "Macro 1 value", exact: true }); + const neutralFocus = editor.getByRole("button", { name: "Macros", exact: true }); + await expect(input).toHaveValue("12.346"); + await input.focus(); await neutralFocus.focus(); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toEqual([]); + // The editor opens/closes host automation gestures on focus. Without a value + // change that must produce neither a parameter write nor an undo command. + await expect(editor.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.gestures)).toEqual([{ id: "macro1", begin: true }, { id: "macro1", begin: false }]); + await page.evaluate(() => { window.freeSuiteQA.gestures = []; }); + expect(await page.evaluate(() => window.freeSuiteQA.schema.parameters.find(p => p.id === "macro1")?.value)).toBe(.1234567); + + await input.fill("23.4567"); await input.press("Enter"); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.writes.length)).toBe(1); + const committed = await page.evaluate(() => window.freeSuiteQA.writes[0]); + expect(committed.id).toBe("macro1"); expect(committed.value).toBeCloseTo(.234567, 12); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.gestures.filter(event => event.id === "macro1"))).toEqual([{ id: "macro1", begin: true }, { id: "macro1", begin: false }]); + await input.fill("80"); await input.press("Escape"); + await expect(input).toHaveValue("23.457"); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toHaveLength(1); + + await input.focus(); + await page.evaluate(() => window.freeSuiteQA.update({ macro1: .7654321 })); + await neutralFocus.focus(); + await expect(input).toHaveValue("76.543"); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toHaveLength(1); + await input.fill("40"); + await page.evaluate(() => window.freeSuiteQA.update({ macro1: .6 })); + await input.press("Escape"); + await expect(input).toHaveValue("60"); + expect(await page.evaluate(() => window.freeSuiteQA.writes)).toHaveLength(1); + await editor.getByRole("button", { name: "Undo", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.freeSuiteQA.schema.parameters.find(p => p.id === "macro1")?.value)).toBe(.1234567); + await expect(editor.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); + await page.evaluate(() => window.freeSuiteQA.dispose()); +}); diff --git a/frontend/e2e/shortcut-foundations.spec.ts b/frontend/e2e/shortcut-foundations.spec.ts index 43c432c..aafb676 100644 --- a/frontend/e2e/shortcut-foundations.spec.ts +++ b/frontend/e2e/shortcut-foundations.spec.ts @@ -189,7 +189,10 @@ test("physical key-position and numpad bindings do not collapse to labels", asyn code: "Digit1", location: 0, }); - expect(topRow.defaultPrevented).toBe(false); + // Digit1 is a global Go to Start shortcut, not the Numpad1 context binding. + expect(topRow).toEqual({ dispatchReturned: false, defaultPrevented: true }); + await expect(page.getByLabel("Last shortcut result")).toContainText('"owner":"registry"'); + await expect(page.getByLabel("Last shortcut result")).toContainText('"actionId":"transport.rewind"'); await expect(page.getByLabel("Context hit counts")).toContainText('"piano_roll":1'); const numpad = await dispatchKey(page, "piano-surface", { @@ -198,6 +201,8 @@ test("physical key-position and numpad bindings do not collapse to labels", asyn location: 3, }); expect(numpad).toEqual({ dispatchReturned: false, defaultPrevented: true }); + await expect(page.getByLabel("Last shortcut result")).toContainText('"owner":"piano_roll"'); + await expect(page.getByLabel("Last shortcut result")).toContainText('"binding":"Numpad1"'); await expect(page.getByLabel("Context hit counts")).toContainText('"piano_roll":2'); }); diff --git a/frontend/e2e/support/freeSuiteHarness.ts b/frontend/e2e/support/freeSuiteHarness.ts new file mode 100644 index 0000000..65fa780 --- /dev/null +++ b/frontend/e2e/support/freeSuiteHarness.ts @@ -0,0 +1,102 @@ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { Page } from "@playwright/test"; +import type { BuiltInPluginSchema } from "../../src/services/NativeBridge"; + +export const nativeFreeSuiteSchemaPath = process.env.OPENSTUDIO_FREE_SUITE_SCHEMAS || fileURLToPath(new URL("../../../output/review/free-suite-ui/editor-schemas.json", import.meta.url)); +// Ordinary frontend-only runs can omit native fixtures. An explicit path is a +// qualification request: a missing or invalid export must fail, never skip. +export const nativeFreeSuiteSchemasUnavailable = !process.env.OPENSTUDIO_FREE_SUITE_SCHEMAS && !existsSync(nativeFreeSuiteSchemaPath); + +export function loadNativeFreeSuiteSchemas(path = nativeFreeSuiteSchemaPath): BuiltInPluginSchema[] { + const result = JSON.parse(readFileSync(path, "utf8")) as { checks?: { schemas?: BuiltInPluginSchema[] }[] }; + const schemas = result.checks?.find(check => Array.isArray(check.schemas))?.schemas; + if (!schemas?.length) throw new Error(`No native editor schemas in ${path}`); + return schemas; +} + +type HarnessState = { + schemas: BuiltInPluginSchema[]; + schema: BuiltInPluginSchema; + writes: { id: string; value: number }[]; + gestures: { id: string; begin: boolean }[]; + audition: { note: number; on: boolean }[]; + mount: (pluginId: string, values?: Record) => void; + update: (values: Record) => void; + dispose: () => void; +}; +declare global { interface Window { freeSuiteQA: HarnessState } } + +/** Real production editor + native-exported schema; the bridge records calls, not audio. */ +export async function installFreeSuiteHarness(page: Page, schemas: BuiltInPluginSchema[]) { + await page.goto("/"); + await page.getByRole("menubar", { name: "Main menu" }).waitFor(); + await page.locator("#openstudio-boot-overlay").waitFor({ state: "hidden" }); + await page.evaluate(async schemas => { + const load = (path: string): Promise => import(/* @vite-ignore */ path); + const { default: React } = await load<{ default: typeof import("react") }>("/node_modules/.vite/deps/react.js"); + const { default: { createRoot } } = await load<{ default: typeof import("react-dom/client") }>("/node_modules/.vite/deps/react-dom_client.js"); + const { BuiltInPluginPanel } = await load("/src/components/BuiltInPluginPanel.tsx"); + const { nativeBridge: bridge } = await load("/src/services/NativeBridge.ts"); + const { useDAWStore: store, createDefaultTrack } = await load("/src/store/useDAWStore.ts"); + store.getState().markInputProfileOnboardingSeen(); + store.setState({ tracks: [createDefaultTrack("suite-qa", "Suite QA", "#4488cc", "audio")], showGettingStarted: false }); + let root: ReturnType | undefined; + let host: HTMLDivElement | undefined; + const render = () => root?.render(React.createElement(BuiltInPluginPanel, { + address: { chain: "track", trackId: "suite-qa", fxIndex: 0 }, fallbackName: state.schema.name, + initialSchema: state.schema, chrome: "detached", onClose: () => {}, + })); + const state: HarnessState = window.freeSuiteQA = { + schemas, schema: structuredClone(schemas[0]), writes: [], gestures: [], audition: [], + dispose() { root?.unmount(); host?.remove(); root = undefined; host = undefined; }, + update(values) { + // Deliver a new native snapshot without remounting the focused/captured controls. + state.schema = structuredClone(state.schema); + for (const parameter of state.schema.parameters) if (values[parameter.id] !== undefined) parameter.value = values[parameter.id]; + render(); + }, + mount(pluginId, values = {}) { + state.dispose(); + const schema = schemas.find(item => item.pluginId === pluginId); + if (!schema) throw new Error(`Missing native schema: ${pluginId}`); + state.schema = structuredClone({ ...schema, chain: "track", fxIndex: 0 }); + for (const parameter of state.schema.parameters) if (values[parameter.id] !== undefined) parameter.value = values[parameter.id]; + state.writes = []; state.gestures = []; state.audition = []; + host = document.createElement("div"); host.dataset.qaEditor = "true"; + host.className = "plugin-editor-window-app fixed inset-0 z-[9999] flex flex-col overflow-hidden bg-daw-dark"; + document.body.append(host); root = createRoot(host); + render(); + }, + }; + bridge.getBuiltInPluginSchema = async () => structuredClone(state.schema); + bridge.getBuiltInPluginMeters = async () => state.schema.visualization ?? null; + bridge.getBuiltInPluginState = async () => ({ name: state.schema.name, values: Object.fromEntries(state.schema.parameters.map(p => [p.id, p.value])) }); + bridge.setBuiltInPluginState = async (_address, snapshot: unknown) => { + const parsed = (typeof snapshot === "string" ? JSON.parse(snapshot) : snapshot) as { values?: Record; factoryDefault?: boolean; hostBypassed?: boolean }; + for (const parameter of state.schema.parameters) { + if (parsed.factoryDefault) parameter.value = parameter.defaultValue; + else if (typeof parsed.values?.[parameter.id] === "number") parameter.value = parsed.values[parameter.id]; + } + if (typeof parsed.hostBypassed === "boolean") state.schema.hostBypassed = parsed.hostBypassed; + return true; + }; + bridge.setBuiltInPluginParam = async (_address, id, value) => { + const parameter = state.schema.parameters.find(p => p.id === id); + if (!parameter) return false; + parameter.value = value; state.writes.push({ id, value }); return true; + }; + bridge.builtInPluginGesture = async (_address, id, begin) => { state.gestures.push({ id, begin }); return true; }; + bridge.sendBuiltInPreview = async (_address, _session, note, on) => { state.audition.push({ note, on }); return true; }; + }, schemas); +} + +export async function mountFreeSuiteEditor(page: Page, pluginId: string, values?: Record) { + await page.evaluate(({ pluginId, values }) => window.freeSuiteQA.mount(pluginId, values), { pluginId, values }); + const editor = page.locator("[data-qa-editor]"); + await editor.locator(".builtin-plugin-panel").waitFor(); + await page.waitForFunction(() => document.querySelector("[data-qa-editor] .builtin-plugin-panel")?.getAttribute("aria-busy") !== "true"); + // Include the initial ResizeObserver delivery and its React layout update. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + return editor; +} diff --git a/frontend/e2e/support/interactionFixtureTypes.ts b/frontend/e2e/support/interactionFixtureTypes.ts new file mode 100644 index 0000000..b0bbe0c --- /dev/null +++ b/frontend/e2e/support/interactionFixtureTypes.ts @@ -0,0 +1,36 @@ +import type Konva from "konva"; +import type { commandManager } from "../../src/store/commands"; +import type { createDefaultTrack, useDAWStore } from "../../src/store/useDAWStore"; + +export interface BrowserRegressionReport { + status: "pass"; + checks: string[]; + recordCalls?: string[]; + zoomIn?: number; + zoomOut?: number; +} + +export interface TrustedTransportKey { key: string; repeat: boolean; trusted: boolean } +export interface TransportKeyQa { + store: typeof useDAWStore; + calls: string[]; + keys: TrustedTransportKey[]; +} +export interface TrackCopyQa { + store: typeof useDAWStore; + commandManager: typeof commandManager; + seed: () => void; +} +export interface MidiAxisQa extends TrackCopyQa { + createDefaultTrack: typeof createDefaultTrack; +} + +declare global { + interface Window { + Konva: typeof Konva; + __trustedTransportKeys: TrustedTransportKey[]; + __transportKeyQA: TransportKeyQa; + __trackCopyQA: TrackCopyQa; + __midiAxisQA: MidiAxisQa; + } +} diff --git a/frontend/e2e/support/midiAxisDrag.ts b/frontend/e2e/support/midiAxisDrag.ts new file mode 100644 index 0000000..28e231d --- /dev/null +++ b/frontend/e2e/support/midiAxisDrag.ts @@ -0,0 +1,93 @@ +import type { Page } from "@playwright/test"; +import type Konva from "konva"; +import type { BrowserRegressionReport, MidiAxisQa } from "./interactionFixtureTypes"; + +export async function runMidiAxisDrag(page: Page): Promise { + function assert(ok: unknown, message: string): asserts ok { if (!ok) throw new Error(message); } + const checks: string[] = []; + await page.goto('/?platform=windows&windowChrome=native'); + await page.getByText('Starting OpenStudio...', { exact: true }).waitFor({ state: 'hidden' }); + await page.evaluate(async () => { + const reactUrl = "/node_modules/.vite/deps/react.js"; + const react = await import(/* @vite-ignore */ reactUrl) as typeof import("react") & { default?: typeof import("react") }; + const React = react.default || react; + const domUrl = "/node_modules/.vite/deps/react-dom_client.js"; + const dom = await import(/* @vite-ignore */ domUrl) as typeof import("react-dom/client") & { default?: typeof import("react-dom/client") }; + const { createRoot } = dom.default || dom; + const url = "/src/components/PianoRoll.tsx"; + const { PianoRoll } = await import(/* @vite-ignore */ url) as typeof import("../../src/components/PianoRoll"); + const componentSource = await (await fetch('/src/components/PianoRoll.tsx')).text(); + const storeUrl = componentSource.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("Piano Roll store import was not found"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../../src/store/useDAWStore"); + const commandsUrl = "/src/store/commands/index.ts"; + const { commandManager } = await import(/* @vite-ignore */ commandsUrl) as typeof import("../../src/store/commands"); + const host = document.createElement('div'); + host.id = 'midi-axis-fixture'; + Object.assign(host.style, { position: 'fixed', inset: '0', zIndex: '99999', display: 'flex', background: '#121212' }); + document.body.append(host); + const root = createRoot(host); + const qa: MidiAxisQa = window.__midiAxisQA = { store, commandManager, createDefaultTrack, seed: () => undefined }; + qa.seed = () => { + const track = createDefaultTrack('m', 'MIDI axis QA', '#4361ee', 'midi', []); + track.midiClips = [{ id: 'notes', name: 'Note group', startTime: 0, duration: 8, sourceLength: 8, color: '#4361ee', events: [ + { timestamp: 0.5, type: 'noteOn', note: 60, velocity: 96 }, + { timestamp: 1.5, type: 'noteOff', note: 60, velocity: 0 }, + { timestamp: 2, type: 'noteOn', note: 64, velocity: 80 }, + { timestamp: 3, type: 'noteOff', note: 64, velocity: 0 }, + ], ccEvents: [], loopLength: 8 }]; + store.setState({ tracks: [track], selectedClipId: 'notes', selectedClipIds: ['notes'], selectedNoteIds: ['notes:0.500000:60', 'notes:2.000000:64'], selectedTrackId: 'm', selectedTrackIds: ['m'], showPianoRoll: true, pianoRollTrackId: 'm', pianoRollClipId: 'notes', pixelsPerSecond: 100, scrollX: 0, scrollY: 0, snapEnabled: true, mouseBehaviorProfileId: 'openstudio', mouseModifiers: {}, globalLocked: false, lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, midiEditorSessions: [], activeMidiEditorSessionId: null, dockedMidiEditorSessionId: null, syncMIDITrackToBackend: async () => {}, transport: { ...store.getState().transport, isPlaying: false, isRecording: false, tempo: 120, currentTime: 0 } }); + commandManager.clear(); + store.setState({ canUndo: false, canRedo: false, isModified: false }); + root.render(React.createElement(PianoRoll, { clipId: 'notes', trackId: 'm' })); + }; + qa.seed(); + }); + const paint = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + const seed = async () => { await page.evaluate(() => window.__midiAxisQA.seed()); await paint(); }; + const read = () => page.evaluate(() => ({ events: window.__midiAxisQA.store.getState().tracks[0].midiClips[0].events, selection: window.__midiAxisQA.store.getState().selectedNoteIds, undo: window.__midiAxisQA.commandManager.getUndoStack().length })); + const point = () => page.evaluate(() => { + const stage = window.Konva.stages.find(stage => stage.container().closest('#midi-axis-fixture') && stage.container().closest('[data-qa="piano-roll-grid-stage"]')); + if (!stage) throw new Error('MIDI stage was not found'); + const node = stage.find('Rect').find(rect => rect.stroke() === '#ffffff' && rect.width() === 100 && rect.height() === 11); + if (!node) throw new Error('selected note body not found'); + const origin = stage.container().getBoundingClientRect(); + return { x: origin.x + node.x() + 30, y: origin.y + node.y() + 6 }; + }); + for (const modifier of ['Control', 'Shift'] as const) { + await seed(); + const before = await read(); + const start = await point(); + await page.keyboard.down(modifier); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 80, start.y + 24, { steps: 5 }); + await page.mouse.up(); + await page.keyboard.up(modifier); + await paint(); + const after = await read(); + const notes = after.events.filter(event => event.type === 'noteOn'); + assert(notes.length === 2 && after.undo === 1, `${modifier}: selected group must move in one Undo ${JSON.stringify(after)}`); + if (modifier === 'Control') { + assert(notes[0].note === 60 && notes[1].note === 64 && Math.abs(notes[0].timestamp - 1.3) < 1e-6 && Math.abs(notes[1].timestamp - 2.8) < 1e-6, `Ctrl drag keeps pitches while time changes unsnapped ${JSON.stringify(after)}`); + } else { + assert(notes[0].timestamp === 0.5 && notes[1].timestamp === 2 && notes[0].note === 58 && notes[1].note === 62, `Shift drag keeps time while pitches change ${JSON.stringify(after)}`); + } + assert(notes[0].velocity === 96 && notes[1].velocity === 80, `${modifier}: velocity must remain`); + await page.evaluate(() => window.__midiAxisQA.store.getState().undo()); + assert(JSON.stringify((await read()).events) === JSON.stringify(before.events), `${modifier}: Undo restores exact source events`); + await page.evaluate(() => window.__midiAxisQA.store.getState().redo()); + assert(JSON.stringify((await read()).events) === JSON.stringify(after.events), `${modifier}: Redo restores group`); + checks.push(`${modifier}: axis-locked two-note group with preserved velocity and exact Undo/Redo`); + } + await seed(); + const start = await point(); + await page.keyboard.down('Shift'); + await page.mouse.click(start.x, start.y); + await page.keyboard.up('Shift'); + await paint(); + const clicked = await read(); + assert(clicked.selection.length === 1 && clicked.undo === 0, `Shift-click must toggle clicked selection without edit ${JSON.stringify(clicked)}`); + checks.push('Shift-click retains selection toggle without history'); + return { status: 'pass', checks }; +} diff --git a/frontend/e2e/support/namOctaverFuzzChecks.ts b/frontend/e2e/support/namOctaverFuzzChecks.ts new file mode 100644 index 0000000..63a8a74 --- /dev/null +++ b/frontend/e2e/support/namOctaverFuzzChecks.ts @@ -0,0 +1,134 @@ +import type { Page } from "@playwright/test"; +import type { BrowserRegressionReport } from "./interactionFixtureTypes"; + +/** Validate responsive pedal geometry and independent Octave/Fuzz state. */ +export async function runNamOctaverFuzzChecks(page: Page): Promise { + function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); + } + const pageErrors: string[] = []; + const onPageError = (error: Error) => pageErrors.push(error.message); + page.on("pageerror", onPageError); + try { + const session = { address: { trackId: 'octaver-fuzz-ui-qa', chain: 'track', fxIndex: 0 }, title: 'OpenStudio NAM Rack', fallbackName: 'OpenStudio NAM Rack' } as const; + const query = new URLSearchParams({ window: 'pluginEditor', platform: 'windows', windowChrome: 'native', mockPlugin: 'nam', sessionId: JSON.stringify(session), namView: 'rack', namFocus: 'gate', namSection: 'pre' }); + await page.goto(`/?${query}`); + const host = page.locator('.nam-rack-design-port.nam-native-design-surface[data-design-section="pre"]'); + await host.waitFor({ state: 'visible', timeout: 20000 }); + const checks: string[] = []; + for (const width of [1920, 1280, 900, 640]) { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate(async () => { + if (document.fonts?.ready) await document.fonts.ready; + await Promise.all(Array.from(document.images).map(image => image.decode().catch(() => {}))); + await new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); + const metrics = await host.evaluate(root => { + const module = root.querySelector('[data-module="octaver"]'); + if (!module) throw new Error('Octaver Fuzz module is missing'); + const box = module.getBoundingClientRect(); + const rect = (element: Element) => { const bounds = element.getBoundingClientRect(); return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, right: bounds.right, bottom: bounds.bottom }; }; + const controls = Array.from(module.querySelectorAll('[data-param-id][role]')).filter(control => control.getBoundingClientRect().width > 0).map(control => ({ id: control.dataset.paramId, role: control.getAttribute('role'), name: control.getAttribute('aria-label'), ...rect(control) })); + const staticItems = Array.from(module.querySelectorAll('.control-label, .foot-action-label, .module-title, .led')).map(element => ({ name: element.textContent || element.className, ...rect(element) })); + const staticCollisions = staticItems.flatMap((item, index) => staticItems.slice(index + 1).flatMap(other => ( + Math.min(item.right, other.right) - Math.max(item.x, other.x) > 0.5 + && Math.min(item.bottom, other.bottom) - Math.max(item.y, other.y) > 0.5 + ? [`${item.name} / ${other.name}`] : [] + ))); + const faceplate = module.querySelector('.module-skin'); + if (!faceplate?.complete || !faceplate.naturalWidth) throw new Error('Octaver Fuzz faceplate did not load'); + return { module: rect(module), controls, staticItems, staticCollisions, overflowX: document.documentElement.scrollWidth > innerWidth + 1, host: rect(root), accessibleName: module.getAttribute('aria-label'), moduleWidth: box.width }; + }); + const ids = [...new Set(metrics.controls.map(control => control.id))]; + for (const id of ['octaverDownMix','octaverUpMix','octaverDirectMix','fuzzDrive','fuzzTone','fuzzLevelDb','fuzzCleanMix','fuzzVoice','octaverEnabled','fuzzEnabled']) assert(ids.includes(id), `${width}px missing interactive ${id} ${JSON.stringify(metrics)}`); + assert(!metrics.overflowX, `${width}px document horizontal overflow`); + assert(metrics.controls.every(control => control.name), `${width}px control missing accessible name`); + assert(metrics.controls.every(control => control.x >= metrics.module.x - 1 && control.y >= metrics.module.y - 1 && control.right <= metrics.module.right + 1 && control.bottom <= metrics.module.bottom + 1), `${width}px control extends outside pedal ${JSON.stringify(metrics)}`); + assert(metrics.accessibleName === 'OCTAVER FUZZ module', `${width}px painted pedal must retain its accessible name`); + assert(metrics.staticCollisions.length === 0, `${width}px overlapping faceplate labels/title/LEDs ${metrics.staticCollisions}`); + assert(metrics.staticItems.every(item => item.x >= metrics.module.x - 1 && item.y >= metrics.module.y - 1 && item.right <= metrics.module.right + 1 && item.bottom <= metrics.module.bottom + 1), `${width}px static faceplate content outside pedal`); + checks.push(`${width}px: pedal controls/labels fit without collisions or overflow and have accessible names`); + } + await page.setViewportSize({ width: 1920, height: 1080 }); + const module = host.locator('[data-module="octaver"]'); + const octave = module.locator('[data-param-id="octaverEnabled"][role="button"]'); + const fuzz = module.locator('[data-param-id="fuzzEnabled"][role="button"]'); + const value = (id: string) => module.locator(`[data-param-id="${id}"][role]`).getAttribute('aria-valuenow'); + await fuzz.click(); + assert(await fuzz.getAttribute('aria-pressed') === 'true' && await octave.getAttribute('aria-pressed') === 'false', 'fuzz-only enable must leave octave bypassed'); + await octave.click(); + assert(await octave.getAttribute('aria-pressed') === 'true' && await fuzz.getAttribute('aria-pressed') === 'true', 'octave can enable independently of fuzz'); + await octave.click(); + assert(await fuzz.getAttribute('aria-pressed') === 'true', 'octave bypass must leave fuzz enabled'); + const voice = module.locator('[data-param-id="fuzzVoice"][role="spinbutton"]'); + await voice.focus(); + await page.keyboard.press('ArrowUp'); + assert(await value('fuzzVoice') === '1' && (await voice.getAttribute('aria-label'))?.includes('Sustain'), 'voice selector changes to Sustain'); + assert((await module.locator('.fuzz-voice-display').textContent() ?? '').toUpperCase() === 'SUSTAIN', 'voice faceplate display updates'); + for (const id of ['fuzzDrive', 'fuzzTone', 'fuzzLevelDb']) { + const knob = module.locator(`[data-param-id="${id}"][role="slider"]`); + const before = Number(await value(id)); + await knob.focus(); + await page.keyboard.press('ArrowUp'); + assert(Number(await value(id)) > before, `${id} keyboard adjustment must update live value`); + const focus = await knob.evaluate(node => ({ width: getComputedStyle(node).outlineWidth, style: getComputedStyle(node).outlineStyle, shadow: getComputedStyle(node).boxShadow })); + assert((Number.parseFloat(focus.width) > 0 && focus.style !== 'none') || focus.shadow !== 'none', `${id} keyboard focus must be visible`); + } + const bridgeState = (): Promise> => page.evaluate(async address => { + const source = await (await fetch('/src/components/NAMRackPanel.tsx')).text(); + const bridgeUrl = source.match(/from "([^"]*NativeBridge\.ts[^"]*)"/)?.[1]; + if (!bridgeUrl) throw new Error('NAM editor bridge import was not found'); + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../../src/services/NativeBridge"); + const state: unknown = await nativeBridge.getBuiltInPluginState(address); + if (!state || typeof state !== 'object' || !('values' in state) + || !state.values || typeof state.values !== 'object') { + throw new Error('NAM bridge did not return parameter values'); + } + const values: Record = {}; + for (const [id, value] of Object.entries(state.values)) { + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`Invalid NAM value: ${id}`); + values[id] = value; + } + return values; + }, session.address); + const state = await bridgeState(); + assert(state.fuzzEnabled === 1 && state.octaverEnabled === 0 && state.fuzzVoice === 1 && state.fuzzDrive > 0.5 && state.fuzzTone > 0.5 && state.fuzzLevelDb > 0, `real bridge state must agree with fuzz-only controls ${JSON.stringify(state)}`); + checks.push('independent fuzz/octave switches, voice display, all fuzz knobs, visible keyboard focus and bridge state'); + await page.locator('[data-qa="nam-premium-signal-chain"]').click(); + const node = page.locator('[data-qa="nam-compact-chain-node-octaver"]'); + await node.waitFor({ state: 'visible' }); + assert((await node.locator('strong').textContent()) === 'Octaver Fuzz', 'compact chain uses the Octaver Fuzz product name'); + assert(await node.getAttribute('data-active') === 'true' && (await node.textContent() ?? '').includes('Sustain Fuzz'), 'compact chain must show active fuzz-only stage and voice'); + const compactPower = node.locator('.nam-compact-chain-power'); + await compactPower.click(); + let powered = await bridgeState(); + assert(powered.octaverEnabled === 0 && powered.fuzzEnabled === 0, 'compact fuzz-only bypass must bypass both stage switches'); + await compactPower.click(); + powered = await bridgeState(); + assert(powered.octaverEnabled === 0 && powered.fuzzEnabled === 1, 'compact fuzz-only power cycle must restore fuzz alone'); + await page.getByRole('button', { name: 'Close signal chain', exact: true }).click(); + await octave.click(); + await page.locator('[data-qa="nam-premium-signal-chain"]').click(); + await compactPower.click(); + powered = await bridgeState(); + assert(powered.octaverEnabled === 0 && powered.fuzzEnabled === 0, 'compact combined bypass must bypass both stage switches'); + await compactPower.click(); + powered = await bridgeState(); + assert(powered.octaverEnabled === 1 && powered.fuzzEnabled === 1, 'compact combined power cycle must restore octave and fuzz'); + checks.push('compact power cycles preserve fuzz-only and combined octave/fuzz enable combinations'); + await page.getByRole('button', { name: 'Close signal chain', exact: true }).click(); + await octave.click(); + await page.locator('[data-qa="nam-premium-signal-chain"]').click(); + await node.locator('.nam-compact-chain-node-main').click(); + await module.locator('[data-param-id="fuzzCleanMix"][role="slider"]').waitFor({ state: 'visible' }); + assert(!await page.locator('[data-qa="nam-rack-mixer"]').isVisible(), 'compact navigation must reveal the complete pedal without an unnecessary advanced panel'); + assert(await fuzz.getAttribute('aria-pressed') === 'true' && await octave.getAttribute('aria-pressed') === 'false', 'compact navigation retains independent stage state'); + checks.push('compact chain reveals the complete pedal including Clean blend and retains fuzz-only state'); + assert(pageErrors.length === 0, `Browser runtime errors: ${pageErrors.join('; ')}`); + return { status: 'pass', checks }; + } finally { + page.off("pageerror", onPageError); + } +} diff --git a/frontend/e2e/support/platformInput.ts b/frontend/e2e/support/platformInput.ts new file mode 100644 index 0000000..36894bd --- /dev/null +++ b/frontend/e2e/support/platformInput.ts @@ -0,0 +1,10 @@ +import type { Page } from "@playwright/test"; + +/** Match the browser's desktop identity, including an explicitly emulated OS. */ +export async function primaryModifier(page: Page): Promise<"Meta" | "Control"> { + return page.evaluate(() => ( + /Mac|iPhone|iPad|iPod/.test(navigator.platform) || /Mac/.test(navigator.userAgent) + ? "Meta" + : "Control" + )); +} diff --git a/frontend/e2e/support/rulerChecks.ts b/frontend/e2e/support/rulerChecks.ts new file mode 100644 index 0000000..ff281ce --- /dev/null +++ b/frontend/e2e/support/rulerChecks.ts @@ -0,0 +1,237 @@ +import type { Page } from "@playwright/test"; +import type { DAWState, useDAWStore } from "../../src/store/useDAWStore"; +import type { BrowserRegressionReport } from "./interactionFixtureTypes"; + +type RulerSeedOverrides = Partial>; +interface RulerPoint { x: number; y: number; relativeX: number } +interface RulerView { + pps: number; + scrollX: number; + scrollY: number; + trackHeight: number; + range: DAWState["projectRange"]; + selection: DAWState["timeSelection"]; + time: number; +} +declare global { + interface Window { + __rulerGestureQA: { store: typeof useDAWStore }; + } +} + +/** Exercise the ruler mounted by App rather than Timeline's optional fixture ruler. */ +export async function runRulerChecks(page: Page): Promise { + const checks: string[] = []; + const pageErrors: string[] = []; + const onPageError = (error: Error) => pageErrors.push(error.message); + page.on('pageerror', onPageError); + function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); + } + const near = (actual: number, expected: number, message: string) => { + assert(Math.abs(actual - expected) < 0.00001, + `${message}: expected ${expected}, received ${actual}`); + }; + const same = (actual: unknown, expected: unknown, message: string) => { + assert(JSON.stringify(actual) === JSON.stringify(expected), + `${message}: ${JSON.stringify({ actual, expected })}`); + }; + const paint = () => page.evaluate(() => new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + })); + + try { + await page.addInitScript(() => { + localStorage.setItem('openstudio_essentialControlsDismissed', 'true'); + }); + await page.goto('/?platform=windows&windowChrome=native'); + await page.getByText('Starting OpenStudio...', { exact: true }).waitFor({ state: 'hidden' }); + await page.evaluate(async () => { + // Follow App's transformed Vite import so HMR cannot create a second store. + const source = await (await fetch('/src/App.tsx')).text(); + const match = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/); + if (!match) throw new Error('Cannot locate the real App store import'); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ match[1]) as typeof import("../../src/store/useDAWStore"); + const track = createDefaultTrack('ruler-browser-qa', 'Ruler browser QA', '#4379ee', 'audio', []); + window.__rulerGestureQA = { store }; + store.setState({ + tracks: [track], selectedTrackId: track.id, selectedTrackIds: [track.id], + selectedClipId: null, selectedClipIds: [], recordingClips: [], recordingMIDIPreviews: {}, + inputProfileOnboardingSeen: true, showGettingStarted: false, showContextualHelp: false, + showMixer: false, showPianoRoll: false, showPitchEditor: false, + keyboardShortcutProfileId: 'openstudio', mouseBehaviorProfileId: 'openstudio', + customShortcuts: {}, mouseModifiers: {}, toolMode: 'select', + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + transport: { ...store.getState().transport, isPlaying: false, isRecording: false, tempo: 120 }, + }); + (document.activeElement as HTMLElement | null)?.blur(); + }); + const canvas = page.locator('.workspace-sticky-ruler canvas'); + await canvas.waitFor({ state: 'visible' }); + await paint(); + assert(await canvas.count() === 1, 'App must mount exactly one visible workspace ruler'); + assert(await page.locator('.workspace-main-row .timeline-container').count() === 1, + 'Real App timeline must remain mounted beside its track controls'); + assert(await page.locator('.timeline-container .sticky canvas').count() === 0, + 'Regression must exercise the extracted App ruler rather than the hidden Timeline ruler'); + + const seed = async (options: RulerSeedOverrides = {}) => { + await page.mouse.up(); + await page.evaluate(options => { + const { store } = window.__rulerGestureQA; + store.setState({ + pixelsPerSecond: 100, scrollX: 500, scrollY: 0, trackHeight: 100, + snapEnabled: false, gridSize: '1/16', timeSignature: { numerator: 4, denominator: 4 }, + projectRange: { start: 2, end: 12 }, timeSelection: { start: 3, end: 4 }, + transport: { ...store.getState().transport, isPlaying: false, isRecording: false, currentTime: 1.25 }, + ...options, + }); + const workspace = document.querySelector('.workspace'); + if (workspace) workspace.scrollTop = 0; + }, options); + await paint(); + }; + const read = () => page.evaluate(() => { + const state = window.__rulerGestureQA.store.getState(); + return { + pps: state.pixelsPerSecond, scrollX: state.scrollX, scrollY: state.scrollY, + trackHeight: state.trackHeight, range: state.projectRange, + selection: state.timeSelection, time: state.transport.currentTime, + }; + }); + const point = async (relativeX?: number, relativeY = 18): Promise => { + const box = await canvas.boundingBox(); + assert(box && box.width > 400 && box.height >= 25, 'Real ruler must have usable visible geometry'); + const x = Math.round(box.x + (relativeX ?? Math.min(300, box.width * 0.4))); + return { x, y: Math.round(box.y + relativeY), relativeX: x - box.x }; + }; + const begin = async (origin: RulerPoint) => { + await page.mouse.move(origin.x, origin.y); + await page.mouse.down(); + }; + const move = async (origin: RulerPoint, dx: number, dy: number) => { + await page.mouse.move(origin.x + dx, origin.y + dy, { steps: 3 }); + await paint(); + }; + const preserveNonViewState = (after: RulerView, before: RulerView, label: string) => { + same(after.range, before.range, `${label}: project range must stay unchanged`); + same(after.selection, before.selection, `${label}: time selection must stay unchanged`); + near(after.time, before.time, `${label}: playhead must stay unchanged`); + near(after.trackHeight, before.trackHeight, `${label}: track height must stay unchanged`); + near(after.scrollY, before.scrollY, `${label}: vertical scroll must stay unchanged`); + }; + const anchored = (after: RulerView, before: RulerView, origin: RulerPoint, label: string) => { + near((after.scrollX + origin.relativeX) / after.pps, + (before.scrollX + origin.relativeX) / before.pps, + `${label}: original time must stay under the pointer-down X position`); + }; + + for (const [label, dy] of [['downward', 45], ['upward', -18]] as const) { + await seed(); + const before = await read(); + const origin = await point(); + await begin(origin); + await move(origin, 0, dy); + await page.mouse.up(); + await paint(); + const after = await read(); + assert(dy > 0 ? after.pps > before.pps : after.pps < before.pps, + `${label} ruler drag must ${dy > 0 ? 'increase' : 'decrease'} time zoom`); + anchored(after, before, origin, label); + preserveNonViewState(after, before, label); + await move(origin, 100, dy + 35); + same(await read(), after, `${label}: mouseup must end the drag`); + checks.push(`${label} zoom keeps its time anchor, range, selection, playhead and track height; mouseup stops it`); + } + + await seed(); + let before = await read(); + let origin = await point(); + await begin(origin); + await move(origin, 1, 12); + await move(origin, 130, 40); + await page.mouse.up(); + await paint(); + let after = await read(); + assert(after.pps > before.pps, 'Vertical-first diagonal drag must keep zoom intent'); + anchored(after, before, origin, 'vertical-first diagonal'); + preserveNonViewState(after, before, 'vertical-first diagonal'); + checks.push('vertical-first diagonal movement keeps zoom intent when horizontal movement becomes larger'); + + await seed(); + before = await read(); + origin = await point(); + await begin(origin); + await move(origin, 25, 1); + await move(origin, 140, 100); + await page.mouse.up(); + await paint(); + after = await read(); + near(after.pps, before.pps, 'Horizontal-first diagonal drag must keep the original zoom'); + near(after.scrollX, before.scrollX, 'Horizontal range selection must keep horizontal scroll'); + const rangeStart = (before.scrollX + origin.relativeX) / before.pps; + near(after.range.start, rangeStart, 'Horizontal ruler drag must preserve existing project-range behavior'); + near(after.range.end, rangeStart + 1.4, 'Horizontal ruler range must follow pointer X after vertical drift'); + same(after.selection, before.selection, 'Horizontal project-range drag must retain the existing time selection'); + near(after.time, before.time, 'Horizontal project-range drag must not seek'); + checks.push('horizontal-first diagonal movement retains existing project-range selection without zoom or seek'); + + for (const handle of ['start', 'end'] as const) { + await seed({ scrollX: 0, projectRange: { start: 1, end: 3 } }); + before = await read(); + origin = await point(handle === 'start' ? 100 : 300, 3); + await begin(origin); + await move(origin, 40, 30); + await page.mouse.up(); + await paint(); + after = await read(); + near(after.range[handle], (before.scrollX + origin.relativeX + 40) / before.pps, + `${handle} range handle must still resize its endpoint`); + near(after.range[handle === 'start' ? 'end' : 'start'], + before.range[handle === 'start' ? 'end' : 'start'], `${handle} range handle must retain the opposite endpoint`); + near(after.pps, before.pps, `${handle} range handle must not zoom`); + same(after.selection, before.selection, `${handle} range handle must not alter time selection`); + near(after.time, before.time, `${handle} range handle must not seek`); + checks.push(`${handle} range handle retains its horizontal resize behavior`); + } + + await seed(); + before = await read(); + origin = await point(); + await begin(origin); + await move(origin, 2, 2); + await page.mouse.up(); + await paint(); + after = await read(); + near(after.time, (before.scrollX + origin.relativeX) / before.pps, + 'Movement below the gesture threshold must still seek to pointer-down time'); + near(after.pps, before.pps, 'Click must not zoom'); + same(after.range, before.range, 'Click must not alter project range'); + same(after.selection, before.selection, 'Click must retain existing time selection'); + checks.push('sub-threshold movement stays a click and seeks without changing zoom or ranges'); + + for (const event of ['blur', 'pointercancel']) { + await seed(); + before = await read(); + origin = await point(); + await begin(origin); + await move(origin, 0, 15); + const atCancel = await read(); + assert(atCancel.pps > before.pps, `${event}: vertical zoom must begin before cancellation`); + await page.evaluate(event => window.dispatchEvent(new Event(event)), event); + await move(origin, 100, 60); + same(await read(), atCancel, `${event}: further movement must not continue zooming`); + await page.mouse.up(); + await paint(); + same(await read(), atCancel, `${event}: releasing a cancelled drag must not seek or change ranges`); + checks.push(`${event} ends an active zoom and prevents later movement or mouseup from editing state`); + } + + assert(pageErrors.length === 0, `Browser runtime errors: ${pageErrors.join('; ')}`); + return { status: 'pass', surface: 'real App .workspace-sticky-ruler', checks }; + } finally { + page.off('pageerror', onPageError); + await page.mouse.up().catch(() => undefined); + } +} diff --git a/frontend/e2e/support/singleKeyTransport.ts b/frontend/e2e/support/singleKeyTransport.ts new file mode 100644 index 0000000..4aa602f --- /dev/null +++ b/frontend/e2e/support/singleKeyTransport.ts @@ -0,0 +1,65 @@ +import type { Page } from "@playwright/test"; +import type { BrowserRegressionReport, TransportKeyQa } from "./interactionFixtureTypes"; + +export async function runSingleKeyTransport(page: Page): Promise { + function assert(ok: unknown, message: string): asserts ok { if (!ok) throw new Error(message); } + await page.addInitScript(() => { + window.__trustedTransportKeys = []; + window.addEventListener('keydown', event => window.__trustedTransportKeys.push({ key: event.key, repeat: event.repeat, trusted: event.isTrusted }), true); + }); + await page.goto('/?platform=windows&windowChrome=native'); + await page.getByText('Starting OpenStudio...', { exact: true }).waitFor({ state: 'hidden' }); + await page.evaluate(async () => { + const source = await (await fetch('/src/App.tsx')).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store import was not found"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../../src/store/useDAWStore"); + const qa: TransportKeyQa = window.__transportKeyQA = { store, calls: [], keys: window.__trustedTransportKeys }; + const track = createDefaultTrack('record-key', 'Record key QA', '#4379ee', 'audio', []); + track.armed = true; + store.setState({ + tracks: [track], selectedTrackId: track.id, selectedTrackIds: [track.id], + selectedClipIds: [], selectedClipId: null, keyboardShortcutProfileId: 'openstudio', + customShortcuts: {}, inputProfileOnboardingSeen: true, showGettingStarted: false, + showPianoRoll: false, showMixer: false, pixelsPerSecond: 100, + transport: { ...store.getState().transport, currentTime: 14, isPlaying: false, isRecording: false }, + toggleRecord: async () => { qa.calls.push(store.getState().recordMode); }, + }); + const input = document.createElement('input'); + input.id = 'transport-key-typing'; + input.setAttribute('aria-label', 'Shortcut typing QA'); + Object.assign(input.style, { position: 'fixed', right: '10px', top: '10px', zIndex: '99999' }); + document.body.append(input); + (document.activeElement as HTMLElement | null)?.blur(); + }); + const read = () => page.evaluate(() => { + const qa = window.__transportKeyQA; + const state = qa.store.getState(); + return { calls: qa.calls, keys: qa.keys, time: state.transport.currentTime, scrollX: state.scrollX, pps: state.pixelsPerSecond }; + }); + await page.keyboard.down('r'); + await page.keyboard.down('r'); + await page.keyboard.up('r'); + assert((await read()).calls.length === 1, 'R invokes shared Record exactly once when held/repeated'); + await page.getByRole('textbox', { name: 'Shortcut typing QA', exact: true }).focus(); + await page.keyboard.press('r'); + assert((await read()).calls.length === 1, 'typing R must not invoke Record'); + await page.evaluate(() => { + (document.activeElement as HTMLElement | null)?.blur(); + const store = window.__transportKeyQA.store; + store.setState({ scrollX: 800, transport: { ...store.getState().transport, currentTime: 14 } }); + }); + await page.keyboard.press('1'); + await page.waitForFunction(() => window.__transportKeyQA.store.getState().transport.currentTime === 0); + const rewound = await read(); + assert(rewound.time === 0 && rewound.scrollX === 0, '1 seeks to absolute zero and shows the project start'); + await page.keyboard.press('Shift+Equal'); + const zoomedIn = await read(); + assert(zoomedIn.pps > 100, '+ zooms the timeline in'); + await page.keyboard.press('Minus'); + const zoomedOut = await read(); + assert(zoomedOut.pps < zoomedIn.pps, '- zooms the timeline out'); + assert(zoomedOut.keys.length >= 6 && zoomedOut.keys.every(event => event.trusted), 'all shortcut inputs must be trusted browser keyboard events'); + assert(zoomedOut.keys.some(event => event.key === 'r' && event.repeat), 'held R test must include a genuine repeat event'); + return { status: 'pass', checks: ['trusted R invokes Record once, ignores repeat and typing', 'trusted 1 seeks and scrolls to absolute project beginning', 'trusted +/- changes timeline zoom'], recordCalls: zoomedOut.calls, zoomIn: zoomedIn.pps, zoomOut: zoomedOut.pps }; +} diff --git a/frontend/e2e/support/timelineCopyChecks.ts b/frontend/e2e/support/timelineCopyChecks.ts new file mode 100644 index 0000000..859a347 --- /dev/null +++ b/frontend/e2e/support/timelineCopyChecks.ts @@ -0,0 +1,464 @@ +import type { Page } from "@playwright/test"; +import type Konva from "konva"; +import type { AudioClip, MIDIClip, Track, DAWState, createDefaultTrack, useDAWStore } from "../../src/store/useDAWStore"; +import type { commandManager } from "../../src/store/commands"; +import type { BrowserRegressionReport } from "./interactionFixtureTypes"; +import { primaryModifier } from "./platformInput"; + +interface SeedOptions { + multi?: boolean; snap?: boolean; snapType?: DAWState["snapType"]; sameStart?: boolean; ruler?: boolean; + hiddenBefore?: boolean; mixedDestinations?: boolean; +} +interface TimelineCopyQa { + store: typeof useDAWStore; + createDefaultTrack: typeof createDefaultTrack; + commandManager: typeof commandManager; + syncs: number; + ruler: boolean; + render: () => void; + holdNextSync: boolean; + releaseSync: (() => void) | null; + backendTracks: Track[]; + removedTrackIds: string[]; + rulerPress?: { zoom: number; scrollX: number; time: number }; + rulerSeekSeen?: boolean; + stopRulerSeekObservation?: () => void; +} +declare global { interface Window { __clipPointerQA: TimelineCopyQa } } + +export async function runTimelineCopyChecks(page: Page): Promise { + const copyModifier = await primaryModifier(page); + const checks: string[] = []; + const pageErrors: string[] = []; + const onPageError = (error: Error) => pageErrors.push(error.message); + page.on('pageerror', onPageError); + try { + function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); + } + await page.goto('/?platform=windows&windowChrome=native'); + await page.getByText('Starting OpenStudio...', { exact: true }).waitFor({ state: 'hidden' }); + await page.evaluate(async () => { + const reactUrl = '/node_modules/.vite/deps/react.js'; + const reactModule = await import(/* @vite-ignore */ reactUrl) as typeof import('react') & { default?: typeof import('react') }; + const React = reactModule.default || reactModule; + const domUrl = '/node_modules/.vite/deps/react-dom_client.js'; + const rootModule = await import(/* @vite-ignore */ domUrl) as typeof import('react-dom/client') & { default?: typeof import('react-dom/client') }; + const { createRoot } = rootModule.default || rootModule; + const componentUrl = '/src/components/Timeline.tsx'; + const { Timeline } = await import(/* @vite-ignore */ componentUrl) as typeof import('../../src/components/Timeline'); + const componentSource = await (await fetch('/src/components/Timeline.tsx')).text(); + const storeUrl = componentSource.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("Timeline store import was not found"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../../src/store/useDAWStore"); + const commandUrl = '/src/store/commands/index.ts'; + const { commandManager } = await import(/* @vite-ignore */ commandUrl) as typeof import('../../src/store/commands'); + const bridgeUrl = '/src/services/NativeBridge.ts'; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import('../../src/services/NativeBridge'); + const qa: TimelineCopyQa = window.__clipPointerQA = { store, createDefaultTrack, commandManager, syncs: 0, + ruler: true, render: () => undefined, holdNextSync: false, releaseSync: null, backendTracks: [], removedTrackIds: [] }; + nativeBridge.getWaveformPeaks = async (_file, _spp, _start, count) => Array.from({ length: count }, (_, index) => ({ channels: [{ min: -0.4 - 0.25 * Math.sin(index * 0.1), max: 0.4 + 0.25 * Math.sin(index * 0.1) }] })); + nativeBridge.addPlaybackClip = async () => true; + nativeBridge.removeTrack = async id => { qa.removedTrackIds.push(id); return true; }; + const host = document.createElement('div'); + host.id = 'clip-pointer-fixture'; + host.className = 'workspace'; + Object.assign(host.style, { position: 'fixed', inset: '0', zIndex: '99999', display: 'flex', background: '#121212' }); + document.body.append(host); + const root = createRoot(host); + const render = () => root.render(React.createElement(Timeline, { tracks: store.getState().getVisibleTracks(), showRuler: qa.ruler })); + qa.render = render; + store.subscribe(render); + render(); + }); + + const seed = async (kind: 'audio' | 'midi' = 'audio', options: SeedOptions = {}) => { + await page.evaluate(({ kind, options }) => { + const qa = window.__clipPointerQA; + const { store, commandManager, createDefaultTrack } = qa; + commandManager.clear(); + qa.syncs = 0; + qa.holdNextSync = false; + qa.releaseSync = null; + qa.removedTrackIds = []; + const source = createDefaultTrack('source', 'Source', '#4361ee', kind, []); + const target = createDefaultTrack('target', 'Target', '#4361ee', kind, []); + const clip = { + id: 'clip', name: kind === 'audio' ? 'Recording / imported audio' : 'MIDI', + startTime: 3.23, duration: 2.03, offset: 0.5, sourceLength: 8, + color: '#4361ee', volumeDB: 0, fadeIn: 0, fadeOut: 0, + ...(kind === 'audio' ? { filePath: 'C:/clip-pointer-fixture.wav', sampleRate: 48000 } + : { events: [], ccEvents: [], loopLength: 8 }), + }; + if (kind === 'audio') source.clips = [clip as AudioClip]; + else source.midiClips = [clip as MIDIClip]; + if (options.multi) { + const other = { ...clip, id: 'other', startTime: options.sameStart ? 3.23 : 1.17 }; + if (kind === 'audio') target.clips = [other as AudioClip]; else target.midiClips = [other as MIDIClip]; + } + const tracks = [source, target]; + if (options.hiddenBefore) { + const folder = { ...createDefaultTrack('folder', 'Collapsed folder', '#4361ee', 'audio', []), isFolder: true, folderCollapsed: true }; + const hidden = { ...createDefaultTrack('hidden', 'Hidden child', '#4361ee', 'audio', []), parentFolderId: folder.id }; + tracks.unshift(folder, hidden); + } + if (options.mixedDestinations) { + target.type = 'midi'; + target.clips = []; + target.midiClips = [{ id: 'other', name: 'MIDI', startTime: 1.17, duration: 2.03, offset: 0, + sourceLength: 8, color: target.color, events: [], ccEvents: [] }]; + tracks.push(createDefaultTrack('midi-destination', 'MIDI destination', '#4361ee', 'midi', []), + createDefaultTrack('audio-destination', 'Audio destination', '#4361ee', 'audio', [])); + } + store.setState({ + tracks, selectedClipId: options.multi ? 'clip' : null, + selectedClipIds: options.multi ? ['clip', 'other'] : [], selectedTrackIds: [], + pixelsPerSecond: 100, scrollX: 0, scrollY: 0, trackHeight: 100, + snapEnabled: options.snap !== false, snapType: options.snapType || 'grid', gridSize: '1/16', + mouseBehaviorProfileId: 'openstudio', mouseModifiers: {}, toolMode: 'select', + globalLocked: false, lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + canUndo: false, canRedo: false, isModified: false, showPianoRoll: false, showPitchEditor: false, + midiEditorSessions: [], activeMidiEditorSessionId: null, dockedMidiEditorSessionId: null, + moveEnvelopesWithItems: true, + transport: { ...store.getState().transport, isPlaying: false, isRecording: false, tempo: 120, currentTime: 0 }, + syncClipsWithBackend: async () => { + qa.syncs++; + const snapshot = structuredClone(store.getState().tracks); + if (qa.holdNextSync) { + qa.holdNextSync = false; + await new Promise(resolve => { qa.releaseSync = resolve; }); + } + qa.backendTracks = snapshot; + }, + }); + qa.ruler = options.ruler !== false; + qa.render(); + }, { kind, options }); + // Allow React and Konva to finish painting the fixture before sending input. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + }; + const read = () => page.evaluate(() => { + const qa = window.__clipPointerQA; + const s = qa.store.getState(); + return { tracks: s.tracks, selected: s.selectedClipIds, dirty: s.isModified, + undo: qa.commandManager.getUndoStack().length, syncs: qa.syncs, editorOpen: s.showPianoRoll }; + }); + const clipOf = (state: Awaited>, id = 'clip') => state.tracks.flatMap(t => [...t.clips, ...t.midiClips]).find(c => c.id === id); + const point = async (relativeX = 70, trackIndex = 0, relativeY = 50) => { + const stage = await page.locator('#clip-pointer-fixture .konvajs-content').last().boundingBox(); + assert(stage, 'main canvas must exist'); + return { x: stage.x + 323 + relativeX, y: stage.y + trackIndex * 100 + relativeY }; + }; + const gesture = async (start: { x: number; y: number }, dx = 0, dy = 0) => { + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + if (dx || dy) await page.mouse.move(start.x + dx, start.y + dy, { steps: 3 }); + await page.mouse.up(); + }; + const paint = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + const previewGeometry = () => page.evaluate(() => { + const stage = window.Konva.stages.find(stage => stage.container().closest('#clip-pointer-fixture') && stage.height() > 50); + if (!stage) throw new Error('Timeline stage was not found'); + return stage.find('Group').filter(node => node.opacity() === 0.5).map(node => ({ + listening: node.listening(), labels: node.find('Text').map(text => text.text()), + rects: node.find('Rect').map(rect => ({ x: rect.x(), y: rect.y(), width: rect.width(), height: rect.height() })), + waveformLines: node.find('Line').filter(line => line.points().length > 10).length, + })); + }); + for (const kind of ['audio', 'midi'] as const) { + await seed(kind, { multi: true, snap: false }); + if (kind === 'midi') await page.evaluate(() => { + const q = window.__clipPointerQA; + q.store.setState({ tracks: q.store.getState().tracks.map(track => ({ ...track, midiClips: track.midiClips.map(clip => ({ ...clip, events: [ + { timestamp: 0.2, type: 'noteOn', note: 60, velocity: 100 }, + { timestamp: 1.5, type: 'noteOff', note: 60, velocity: 0 }, + ] })) })) }); + }); + await paint(); + const before = await read(); + const origin = await point(); + await page.keyboard.down(copyModifier); + await page.mouse.move(origin.x, origin.y); + await page.mouse.down(); + await page.mouse.move(origin.x + 140, origin.y, { steps: 5 }); + await paint(); + const during = await read(); + assert(JSON.stringify(during.tracks) === JSON.stringify(before.tracks), `${kind}: originals must remain stationary until drop`); + const previews = await previewGeometry(); + assert(previews.length === 2 && previews.every(preview => !preview.listening), `${kind}: entire selection must have noninteractive 50% opacity previews ${JSON.stringify(previews)}`); + assert(previews.every(preview => preview.labels.length > 0 && preview.rects.length > 0), `${kind}: previews must contain real clip labels/content`); + if (kind === 'audio') assert(previews.every(preview => preview.waveformLines > 0), 'audio: every copy must show its waveform'); + else assert(previews.every(preview => preview.rects.some(rect => rect.height <= 4 && rect.width > 10)), 'MIDI: every copy must show its note thumbnail'); + const previewStarts = previews.map(preview => preview.rects[0].x).sort((a, b) => a - b); + assert(Math.abs(previewStarts[1] - previewStarts[0] - 206) < 0.1, `${kind}: preview timing spacing must remain 206px ${previewStarts}`); + await page.mouse.up(); + await page.keyboard.up(copyModifier); + await paint(); + const after = await read(); + const originals = after.tracks.flatMap(track => [...track.clips, ...track.midiClips]).filter(clip => ['clip', 'other'].includes(clip.id)); + const copies = after.tracks.flatMap(track => [...track.clips, ...track.midiClips]).filter(clip => !['clip', 'other'].includes(clip.id)); + assert(copies.length === 2 && after.undo === 1, `${kind}: group copy must be one edit ${JSON.stringify(after)}`); + assert(JSON.stringify(originals) === JSON.stringify(before.tracks.flatMap(track => [...track.clips, ...track.midiClips])), `${kind}: originals must remain unchanged after drop`); + assert(copies.every(copy => Math.abs(copy.startTime - clipOf(before, copy.startTime > 4 ? 'clip' : 'other')!.startTime - 1.4) < 0.001), `${kind}: group copies must share one delta`); + await page.evaluate(() => window.__clipPointerQA.store.getState().undo()); + assert(JSON.stringify((await read()).tracks) === JSON.stringify(before.tracks), `${kind}: one Undo restores all sources exactly`); + await page.evaluate(() => window.__clipPointerQA.store.getState().redo()); + assert(JSON.stringify((await read()).tracks) === JSON.stringify(after.tracks), `${kind}: Redo restores stable copy identities`); + checks.push(`${kind}: two 50% previews with labels/content, stationary originals, shared offset, single Undo/Redo`); + + for (const cancel of ['Escape', 'blur', 'pointercancel']) { + await seed(kind, { multi: true, snap: false }); + const beforeCancel = await read(); + const start = await point(); + await page.keyboard.down(copyModifier); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 140, start.y, { steps: 4 }); + if (cancel === 'Escape') await page.keyboard.press('Escape'); + else await page.evaluate(event => window.dispatchEvent(new Event(event)), cancel); + await page.mouse.up(); + await page.keyboard.up(copyModifier); + await paint(); + const afterCancel = await read(); + assert(JSON.stringify(afterCancel.tracks) === JSON.stringify(beforeCancel.tracks) && afterCancel.undo === 0 && (await previewGeometry()).length === 0, `${kind}: ${cancel} must clear every preview without copies/history`); + checks.push(`${kind}: ${cancel} cancels entire selection copy`); + } + } + await seed('audio', { multi: true, snap: false, hiddenBefore: true }); + const beforeCollapsed = await read(); + const collapsedOrigin = await point(70, 1); + await page.keyboard.down(copyModifier); + await page.mouse.move(collapsedOrigin.x, collapsedOrigin.y); + await page.mouse.down(); + await page.mouse.move(collapsedOrigin.x + 140, collapsedOrigin.y, { steps: 5 }); + await paint(); + const collapsedPreviews = await previewGeometry(); + assert(collapsedPreviews.length === 2, 'collapsed-folder rows must retain both clip-copy previews'); + await page.mouse.up(); + await page.keyboard.up(copyModifier); + await paint(); + const afterCollapsed = await read(); + assert(afterCollapsed.tracks.find(track => track.id === 'hidden')?.clips.length === 0, + 'copy must not accidentally target a hidden row'); + assert(['source', 'target'].every(id => afterCollapsed.tracks.find(track => track.id === id)?.clips.length === 2), + 'copied clips must stay on their requested visible source rows'); + assert(afterCollapsed.undo === 1, 'collapsed-folder group copy must be one Undo step'); + await page.evaluate(() => window.__clipPointerQA.store.getState().undo()); + assert(JSON.stringify((await read()).tracks) === JSON.stringify(beforeCollapsed.tracks), 'collapsed-folder copy Undo restores exact tracks'); + checks.push('collapsed-folder clip copies preserve two previews, visible destinations and one Undo'); + + await seed('audio', { multi: true, snap: false, mixedDestinations: true }); + const beforeMixed = await read(); + const mixedOrigin = await point(); + await page.keyboard.down(copyModifier); + await page.mouse.move(mixedOrigin.x, mixedOrigin.y); + await page.mouse.down(); + await page.mouse.move(mixedOrigin.x + 140, mixedOrigin.y + 100, { steps: 5 }); + await paint(); + const mixedPreviews = await previewGeometry(); + assert(mixedPreviews.length === 2 && mixedPreviews.every(preview => preview.rects[0].y >= 400), + 'mixed group must preview its real generated rows, not incompatible existing destinations'); + await page.mouse.up(); + await page.keyboard.up(copyModifier); + await paint(); + const afterMixed = await read(); + assert(afterMixed.tracks.length === 6 && afterMixed.tracks[4].clips.length === 1 && afterMixed.tracks[5].midiClips.length === 1, + 'an incompatible destination must append the whole ordered mixed group'); + assert(afterMixed.undo === 1, 'generated tracks and their copies must be one Undo step'); + await page.evaluate(() => window.__clipPointerQA.store.getState().undo()); + assert(JSON.stringify((await read()).tracks) === JSON.stringify(beforeMixed.tracks), 'Undo removes generated rows and restores the original group'); + await page.evaluate(() => window.__clipPointerQA.store.getState().redo()); + assert(JSON.stringify((await read()).tracks) === JSON.stringify(afterMixed.tracks), 'Redo restores generated rows and stable copy identities'); + checks.push('mixed copy destinations share actual generated preview rows and exact Undo/Redo'); + + // Keep the simple gesture path covered independently of selected-group state. + await seed('audio', { snap: false, hiddenBefore: true }); + const single = await point(70, 1); + await page.keyboard.down(copyModifier); + await gesture(single, 80, 0); + await page.keyboard.up(copyModifier); + await paint(); + assert((await read()).tracks.find(track => track.id === 'source')?.clips.length === 2, + 'single copies also resolve the actual visible source ID after a collapsed folder'); + checks.push('single copy after a collapsed folder resolves its destination by ID'); + + await seed('audio', { snap: false, hiddenBefore: true }); + await page.evaluate(() => { + const q = window.__clipPointerQA; + const state = q.store.getState(); + const hidden = state.tracks.find(track => track.id === 'hidden')!; + const source = state.tracks.find(track => track.id === 'source')!; + const secondHidden = { ...q.createDefaultTrack('second-hidden', 'Second hidden child', '#4361ee', 'audio', []), parentFolderId: 'folder' }; + const tracks = state.tracks.map(track => track.id === hidden.id + ? { ...track, clips: [{ ...source.clips[0], id: 'hidden-clip' }] } : track); + tracks.splice(2, 0, secondHidden); + q.store.setState({ tracks, selectedClipId: 'clip', selectedClipIds: ['hidden-clip', 'clip'] }); + }); + await paint(); + const beforeHiddenSelection = await read(); + const hiddenSelectionOrigin = await point(70, 1); + await page.keyboard.down(copyModifier); + await page.mouse.move(hiddenSelectionOrigin.x, hiddenSelectionOrigin.y); + await page.mouse.down(); + await page.mouse.move(hiddenSelectionOrigin.x + 80, hiddenSelectionOrigin.y, { steps: 5 }); + await paint(); + assert((await previewGeometry()).length === 2, 'selected hidden sources must render safely alongside the visible anchor'); + await page.mouse.up(); + await page.keyboard.up(copyModifier); + await paint(); + const hiddenCopies = await read(); + assert(hiddenCopies.tracks.length === 8 && hiddenCopies.selected.length === 2 && hiddenCopies.undo === 1, + 'hidden sources remain in the copied group, with their row gap preserved'); + await page.evaluate(() => window.__clipPointerQA.store.getState().undo()); + assert(JSON.stringify((await read()).tracks) === JSON.stringify(beforeHiddenSelection.tracks), 'hidden-source copy Undo restores the entire original tree'); + checks.push('selected hidden sources keep safe preview metrics, row gaps and atomic Undo'); + + await seed('audio', { multi: true, snap: false, mixedDestinations: true }); + const beforePending = await read(); + await page.evaluate(() => { window.__clipPointerQA.holdNextSync = true; }); + const pendingOrigin = await point(); + await page.keyboard.down(copyModifier); + await gesture(pendingOrigin, 140, 100); + await page.keyboard.up(copyModifier); + await page.waitForFunction(() => window.__clipPointerQA.releaseSync !== null); + const pendingIds = (await read()).tracks.slice(4).map(track => track.id); + assert(pendingIds.length === 2, 'pending copy must have generated both destination rows'); + await page.evaluate(() => window.dispatchEvent(new Event('pointercancel'))); + await page.evaluate(() => { + const q = window.__clipPointerQA; + q.releaseSync?.(); q.releaseSync = null; + }); + await page.waitForFunction(ids => { + const q = window.__clipPointerQA; + return q.backendTracks.length === 4 && ids.every(id => q.removedTrackIds.includes(id)); + }, pendingIds); + const afterPending = await read(); + assert(afterPending.undo === 0 && JSON.stringify(afterPending.tracks) === JSON.stringify(beforePending.tracks), + 'cancelling a pending backend write must restore frontend tracks without history'); + assert(await page.evaluate(() => JSON.stringify(window.__clipPointerQA.backendTracks)) === JSON.stringify(beforePending.tracks), + 'pending backend content must be reconciled after cancellation, including generated-track removal'); + checks.push('cancellation during backend synchronization restores topology and content without Undo'); + + // The optional inline ruler has its own gesture handlers. Exercise real + // pointer input here as well as the extracted App ruler's recording tests. + for (const termination of ['vertical zoom', 'horizontal selection', 'mouseup', 'pointercancel', 'blur'] as const) { + await seed('audio', { snap: false }); + await page.evaluate(() => { + const { store } = window.__clipPointerQA; + store.setState({ + autoScrollDuringPlayback: true, projectRange: { start: 0, end: 600 }, timeSelection: null, + // Keep real App playback active past the original short copy fixtures. + tracks: store.getState().tracks.map(track => ({ ...track, + clips: track.clips.map(clip => ({ ...clip, duration: 600, sourceLength: 601 })) })), + transport: { ...store.getState().transport, isPlaying: true, currentTime: 1, loopEnabled: false }, + }); + }); + await paint(); + const ruler = page.locator('#clip-pointer-fixture .sticky canvas'); + const bounds = await ruler.boundingBox(); + assert(bounds && bounds.width > 400, 'inline ruler must have usable geometry'); + const anchorX = 300; + await page.mouse.move(bounds.x + anchorX, bounds.y + 18); + // Queue automatic follow before Konva receives this same native press. + // A pending follow RAF must not move the view during axis arbitration. + await ruler.evaluate(element => element.addEventListener('mousedown', () => { + const qa = window.__clipPointerQA; + const state = qa.store.getState(); + qa.rulerPress = { zoom: state.pixelsPerSecond, scrollX: state.scrollX, time: state.transport.currentTime }; + state.setCurrentTime(Math.max(state.transport.currentTime + 30, + (state.scrollX + element.getBoundingClientRect().width + 100) / state.pixelsPerSecond)); + }, { capture: true, once: true })); + await page.mouse.down(); + const pressed = await page.evaluate(() => { + const qa = window.__clipPointerQA; + const state = qa.store.getState(); + return { ...qa.rulerPress!, held: state.isTimelineRulerZooming, advancedTime: state.transport.currentTime }; + }); + assert(pressed.held, `${termination}: inline seek press must protect its view before movement`); + assert(pressed.advancedTime * pressed.zoom - pressed.scrollX > bounds.width, + `${termination}: advancing transport must really require automatic follow`); + // Advance transport beyond the normal 200ms idle suppression while keeping + // the physical button held and the pointer below the movement threshold. + await page.evaluate(() => new Promise(resolve => { + const { store } = window.__clipPointerQA; + const started = performance.now(); + const time = store.getState().transport.currentTime; + const tick = (now: number) => { + store.getState().setCurrentTime(time + (now - started) / 1000); + if (now - started >= 350) resolve(); + else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + })); + const held = await page.evaluate(() => { + const state = window.__clipPointerQA.store.getState(); + return { zoom: state.pixelsPerSecond, scrollX: state.scrollX, time: state.transport.currentTime }; + }); + assert(held.time >= pressed.advancedTime + 0.3, `${termination}: transport must continue during the pending press`); + assert(held.zoom === pressed.zoom && Math.abs(held.scrollX - pressed.scrollX) < 0.00001, + `${termination}: pending inline press must suppress both queued and subsequent automatic follow`); + + if (termination === 'vertical zoom' || termination === 'horizontal selection') { + await page.mouse.move(bounds.x + anchorX + (termination === 'horizontal selection' ? 90 : 0), + bounds.y + 18 + (termination === 'vertical zoom' ? 90 : 0)); + await paint(); + const locked = await page.evaluate(() => { + const state = window.__clipPointerQA.store.getState(); + return { zoom: state.pixelsPerSecond, scrollX: state.scrollX, + held: state.isTimelineRulerZooming, selection: state.timeSelection }; + }); + if (termination === 'vertical zoom') { + assert(locked.held && locked.zoom > pressed.zoom, 'inline vertical axis must retain protection and zoom'); + assert(Math.abs((locked.scrollX + anchorX) / locked.zoom - (pressed.scrollX + anchorX) / pressed.zoom) < 0.00001, + 'inline vertical zoom must retain the original press anchor'); + } else { + assert(!locked.held && locked.zoom === pressed.zoom, 'inline horizontal axis must release zoom protection'); + assert(locked.selection && Math.abs(locked.selection.start - (pressed.scrollX + anchorX) / pressed.zoom) < 0.00001 + && Math.abs(locked.selection.end - (pressed.scrollX + anchorX + 90) / pressed.zoom) < 0.00001, + 'inline horizontal movement must retain its ordinary time-selection behavior'); + } + } else if (termination !== 'mouseup') { + await page.evaluate(event => window.dispatchEvent(new Event(event)), termination); + } + if (termination === 'mouseup') { + await page.evaluate(target => { + const qa = window.__clipPointerQA; + qa.rulerSeekSeen = false; + // seekTo can await native pause before publishing its exact position; + // retain that publication even if playback advances before CDP reads it. + qa.stopRulerSeekObservation = qa.store.subscribe(state => { + if (Math.abs(state.transport.currentTime - target) < 0.00001) qa.rulerSeekSeen = true; + }); + }, (pressed.scrollX + anchorX) / pressed.zoom); + } + await page.mouse.up(); + if (termination === 'mouseup') { + await page.waitForFunction(() => window.__clipPointerQA.rulerSeekSeen === true); + await page.evaluate(() => window.__clipPointerQA.stopRulerSeekObservation?.()); + } + const released = await page.evaluate(() => { + const state = window.__clipPointerQA.store.getState(); + return { held: state.isTimelineRulerZooming, scrollX: state.scrollX, time: state.transport.currentTime, range: state.projectRange }; + }); + assert(!released.held, `${termination}: inline gesture termination must release protection`); + assert(released.range.start === 0 && released.range.end === 600, `${termination}: inline gesture must retain the project range`); + await page.waitForFunction(scrollX => { + const { store } = window.__clipPointerQA; + store.getState().setCurrentTime(Math.max(60, store.getState().transport.currentTime) + 0.1); + return store.getState().scrollX > scrollX; + }, released.scrollX); + checks.push(`inline ruler: queued follow and pending hold preserve the press anchor; ${termination} retains its behavior and releases follow`); + } + await seed('audio', { snap: false }); + assert(pageErrors.length === 0, `browser runtime errors: ${pageErrors.join('; ')}`); + return { status: 'pass', checks }; + } finally { + await page.evaluate(() => { + window.__clipPointerQA?.releaseSync?.(); + window.__clipPointerQA?.stopRulerSeekObservation?.(); + }); + await page.mouse.up(); + await page.keyboard.up(copyModifier); + page.off('pageerror', onPageError); + } +} diff --git a/frontend/e2e/support/trackSelectionCopy.ts b/frontend/e2e/support/trackSelectionCopy.ts new file mode 100644 index 0000000..5cad20f --- /dev/null +++ b/frontend/e2e/support/trackSelectionCopy.ts @@ -0,0 +1,84 @@ +import type { Page } from "@playwright/test"; +import type { BrowserRegressionReport, TrackCopyQa } from "./interactionFixtureTypes"; + +declare global { + interface Window { + __trackCopyModules: { + store: typeof import("../../src/store/useDAWStore").useDAWStore; + createDefaultTrack: typeof import("../../src/store/useDAWStore").createDefaultTrack; + commandManager: typeof import("../../src/store/commands").commandManager; + }; + } +} + +export async function runTrackSelectionCopy(page: Page): Promise { + function assert(ok: unknown, message: string): asserts ok { if (!ok) throw new Error(message); } + const checks: string[] = []; + await page.goto('/?platform=windows&windowChrome=native'); + await page.getByText('Starting OpenStudio...', { exact: true }).waitFor({ state: 'hidden' }); + await page.getByRole('contentinfo', { name: 'Transport controls' }).waitFor(); + // Keep the pending module imports owned by the document. A CDP evaluation's + // dynamic-import Promise can be collected before Vite finishes the load. + const response = await page.request.get('/src/App.tsx'); + if (!response.ok()) throw new Error("App source was not available"); + const source = await response.text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store import was not found"); + await page.addScriptTag({ type: 'module', content: ` + import { useDAWStore as store, createDefaultTrack } from ${JSON.stringify(storeUrl)}; + import { commandManager } from "/src/store/commands/index.ts"; + window.__trackCopyModules = { store, createDefaultTrack, commandManager }; + ` }); + await page.waitForFunction(() => Boolean(window.__trackCopyModules)); + await page.evaluate(() => { + const { store, createDefaultTrack, commandManager } = window.__trackCopyModules; + const qa: TrackCopyQa = window.__trackCopyQA = { store, commandManager, seed: () => undefined }; + qa.seed = () => { + commandManager.clear(); + const a = createDefaultTrack('a', 'Copy Alpha', '#4379ee', 'audio', []); + a.clips = [{ id: 'ac', name: 'Alpha audio', filePath: 'fixture.wav', startTime: 1, duration: 2, offset: 0, volumeDB: -3, color: a.color, fadeIn: 0, fadeOut: 0 }]; + const b = createDefaultTrack('b', 'Copy Beta', '#43ee99', 'midi', []); + b.midiClips = [{ id: 'bc', name: 'Beta MIDI', startTime: 3, duration: 2, sourceLength: 2, events: [{ timestamp: 0.2, type: 'noteOn', note: 60, velocity: 80 }], ccEvents: [], color: b.color }]; + const c = createDefaultTrack('c', 'Destination', '#ee9943', 'audio', []); + store.setState({ tracks: [a,b,c], selectedTrackId: 'b', selectedTrackIds: ['a','b'], selectedClipIds: [], selectedClipId: null, trackHeight: 100, showPianoRoll: false, showMixer: false, inputProfileOnboardingSeen: true, globalLocked: false, lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, canUndo: false, canRedo: false }); + }; + qa.seed(); + }); + const paint = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + const read = () => page.evaluate(() => ({ tracks: window.__trackCopyQA.store.getState().tracks, undo: window.__trackCopyQA.commandManager.getUndoStack().length })); + await paint(); + const origin = await page.locator('[data-track-id="a"]').boundingBox(); + assert(origin, 'source header must exist'); + const before = await read(); + await page.keyboard.down('Control'); + await page.mouse.move(origin.x + 35, origin.y + 90); + await page.mouse.down(); + await page.mouse.move(origin.x + 150, origin.y + 90, { steps: 5 }); + await paint(); + const preview = page.locator('[data-track-drag-copy="true"]'); + await preview.waitFor({ state: 'visible' }); + assert(await preview.evaluate(node => Number(getComputedStyle(node).opacity) === 0.5), 'track copy preview must have 50% opacity'); + const previewText = await preview.textContent(); + assert(previewText?.includes('Copy Alpha') && previewText.includes('Copy Beta'), 'preview must contain every selected header'); + const during = await read(); + assert(JSON.stringify(during.tracks) === JSON.stringify(before.tracks), 'track originals remain unchanged while copying'); + const duringBox = await page.locator('[data-track-id="a"]').boundingBox(); + assert(duringBox, 'source header remains visible during copying'); + assert(Math.abs(duringBox.x - origin.x) < 1 && Math.abs(duringBox.y - origin.y) < 1, 'source header stays stationary during copy drag'); + await page.mouse.up(); + await page.keyboard.up('Control'); + await page.waitForFunction(() => window.__trackCopyQA.store.getState().tracks.length === 5); + const after = await read(); + assert(after.undo === 1, 'whole selected-track copy must be one Undo command'); + const copies = after.tracks.filter(track => !['a','b','c'].includes(track.id)); + assert(copies.length === 2 && copies[0].clips.length === 1 && copies[1].midiClips.length === 1, 'whole copied tracks retain audio/MIDI content'); + assert(copies[0].clips[0].id !== 'ac' && copies[1].midiClips[0].id !== 'bc', 'copied tracks have independent clip identities'); + await page.evaluate(() => window.__trackCopyQA.store.getState().undo()); + await page.waitForFunction(() => window.__trackCopyQA.store.getState().tracks.length === 3); + assert(JSON.stringify((await read()).tracks) === JSON.stringify(before.tracks), 'one Undo restores originals exactly'); + await page.evaluate(() => window.__trackCopyQA.store.getState().redo()); + await page.waitForFunction(() => window.__trackCopyQA.store.getState().tracks.length === 5); + assert(JSON.stringify((await read()).tracks) === JSON.stringify(after.tracks), 'Redo restores copied tracks with stable identities'); + checks.push('Ctrl horizontal header drag previews all selected tracks at 50%, keeps sources stationary, copies full content with atomic Undo/Redo'); + return { status: 'pass', checks }; +} diff --git a/frontend/e2e/timeline-controls.spec.ts b/frontend/e2e/timeline-controls.spec.ts new file mode 100644 index 0000000..96dc416 --- /dev/null +++ b/frontend/e2e/timeline-controls.spec.ts @@ -0,0 +1,157 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { createDefaultTrack, useDAWStore } from "../src/store/useDAWStore"; +import type { commandManager } from "../src/store/commands"; +import { runSingleKeyTransport } from "./support/singleKeyTransport"; +import { runMidiAxisDrag } from "./support/midiAxisDrag"; +import { runTrackSelectionCopy } from "./support/trackSelectionCopy"; + +declare global { + interface Window { + __timelineControlsQA: { + store: typeof useDAWStore; + createDefaultTrack: typeof createDefaultTrack; + commandManager: typeof commandManager; + }; + } +} + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("openstudio.inputProfiles.v1", JSON.stringify({ + schemaVersion: 1, keyboardProfileId: "openstudio", mouseProfileId: "openstudio", onboardingSeen: true, + })); + localStorage.setItem("openstudio_essentialControlsDismissed", "true"); + }); +}); + +for (const [name, run] of [ + ["trusted transport keys ignore repeats and text entry", runSingleKeyTransport], + ["MIDI note modifiers preserve the selected group and Undo", runMidiAxisDrag], + ["track copy previews keep sources stationary and commit one Undo", runTrackSelectionCopy], +] as const) { + test(name, async ({ page }, testInfo) => { + const report = await run(page); + expect(report.status).toBe("pass"); + expect(report.checks.length).toBeGreaterThan(0); + await testInfo.attach("interaction-report", { body: JSON.stringify(report, null, 2), contentType: "application/json" }); + }); +} + +async function ready(page: Page) { + await page.goto("/?platform=windows&windowChrome=native"); + await expect(page.getByText("Starting OpenStudio...", { exact: true })).toBeHidden(); + await page.evaluate(async () => { + const source = await (await fetch("/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store import was not found"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const commandsUrl = "/src/store/commands/index.ts"; + const { commandManager } = await import(/* @vite-ignore */ commandsUrl) as typeof import("../src/store/commands"); + window.__timelineControlsQA = { store, createDefaultTrack, commandManager }; + const track = createDefaultTrack("ruler-track", "Ruler track", "#4361ee", "audio", []); + store.setState({ + tracks: [track], selectedClipIds: [], selectedClipId: null, showMixer: false, showGettingStarted: false, + showPianoRoll: false, showPitchEditor: false, inputProfileOnboardingSeen: true, + pixelsPerSecond: 100, scrollX: 0, scrollY: 0, snapEnabled: false, + mouseBehaviorProfileId: "openstudio", mouseModifiers: {}, timeSelection: null, projectRange: { start: 0, end: 20 }, + transport: { ...store.getState().transport, currentTime: 1, isPlaying: false, isRecording: false, loopEnabled: false }, + }); + }); +} + +async function rulerDrag(page: Page, dx: number, dy: number, x = 300, y = 18) { + const bounds = await page.locator(".workspace-sticky-ruler canvas").first().boundingBox(); + if (!bounds) throw new Error("Visible ruler was not found"); + await page.mouse.move(bounds.x + x, bounds.y + y); + await page.mouse.down(); + await page.mouse.move(bounds.x + x + dx, bounds.y + y + dy, { steps: 5 }); + await page.mouse.up(); +} + +const view = (page: Page) => page.evaluate(() => { + const state = window.__timelineControlsQA.store.getState(); + return { zoom: state.pixelsPerSecond, scrollX: state.scrollX, range: state.projectRange, selection: state.timeSelection, loop: state.transport.loopEnabled }; +}); + +test("menu zoom uses the same complete range as keyboard actions", async ({ page }) => { + await ready(page); + await page.evaluate(() => window.__timelineControlsQA.store.setState({ pixelsPerSecond: 900 })); + await page.getByRole("menuitem", { name: "View menu", exact: true }).click(); + await page.getByRole("menuitem", { name: "Zoom In", exact: true }).click(); + await expect.poll(async () => (await view(page)).zoom).toBe(1000); + await page.evaluate(() => window.__timelineControlsQA.store.setState({ pixelsPerSecond: 2 })); + await page.getByRole("menuitem", { name: "View menu", exact: true }).click(); + await page.getByRole("menuitem", { name: "Zoom Out", exact: true }).click(); + await expect.poll(async () => (await view(page)).zoom).toBeCloseTo(4 / 3); +}); + +test("visible ruler keeps plain range dragging and routes custom actions", async ({ page }) => { + await ready(page); + await rulerDrag(page, 100, 0); + expect(await view(page)).toMatchObject({ zoom: 100, range: { start: 3, end: 4 }, selection: null, loop: false }); + await page.evaluate(() => window.__timelineControlsQA.store.setState({ mouseModifiers: { ruler_click: { none: "none" } } })); + const beforeNoop = await view(page); + await rulerDrag(page, 80, 40); + expect(await view(page)).toEqual(beforeNoop); + await page.evaluate(() => window.__timelineControlsQA.store.setState({ mouseModifiers: { ruler_click: { none: "time_select" } } })); + await rulerDrag(page, 100, 0); + expect(await view(page)).toMatchObject({ zoom: 100, range: { start: 3, end: 4 }, selection: { start: 1, end: 4 } }); + await page.evaluate(() => window.__timelineControlsQA.store.setState({ mouseModifiers: { ruler_click: { none: "loop_set" } } })); + await rulerDrag(page, 100, 0, 400); + expect(await view(page)).toMatchObject({ zoom: 100, range: { start: 4, end: 5 }, loop: true }); + await page.evaluate(() => window.__timelineControlsQA.store.setState({ mouseModifiers: { ruler_click: { none: "zoom_to" } } })); + await rulerDrag(page, 100, 0); + const zoomed = await view(page); + expect(zoomed.zoom).toBeGreaterThan(100); + expect(zoomed.scrollX / zoomed.zoom).toBeCloseTo(3); + expect(zoomed.range).toEqual({ start: 4, end: 5 }); +}); + +test("mapped ruler handles remain project-range edits and vendor no-ops stay inactive", async ({ page }) => { + await ready(page); + await page.evaluate(() => window.__timelineControlsQA.store.setState({ projectRange: { start: 2, end: 5 } })); + await rulerDrag(page, 100, 0, 200, 4); + expect((await view(page)).range).toEqual({ start: 3, end: 5 }); + await page.evaluate(() => window.__timelineControlsQA.store.setState({ mouseBehaviorProfileId: "audacity", mouseModifiers: {} })); + const before = await view(page); + await page.keyboard.down("Alt"); + await rulerDrag(page, 100, 40); + await page.keyboard.up("Alt"); + expect(await view(page)).toEqual(before); +}); + +test("copying a collapsed folder includes children and supports atomic Undo/Redo", async ({ page }) => { + await ready(page); + await page.evaluate(() => { + const { store, createDefaultTrack, commandManager } = window.__timelineControlsQA; + commandManager.clear(); + const folder = createDefaultTrack("folder", "Collapsed Folder", "#4361ee", "audio", []); + folder.isFolder = true; + folder.folderCollapsed = true; + const child = createDefaultTrack("child", "Hidden Child", "#4361ee", "audio", [folder]); + child.parentFolderId = folder.id; + const outside = createDefaultTrack("outside", "Outside", "#4361ee", "audio", [folder, child]); + store.setState({ tracks: [folder, child, outside], selectedTrackId: folder.id, selectedTrackIds: [folder.id], trackHeight: 100 }); + }); + const source = await page.locator('[data-track-id="folder"]').boundingBox(); + if (!source) throw new Error("Folder header was not found"); + await page.keyboard.down("Control"); + await page.mouse.move(source.x + 60, source.y + 90); + await page.mouse.down(); + await page.mouse.move(source.x + 170, source.y + 90, { steps: 5 }); + await expect(page.locator('[data-track-drag-copy="true"]')).toContainText("Hidden Child"); + await page.mouse.up(); + await page.keyboard.up("Control"); + await expect.poll(() => page.evaluate(() => window.__timelineControlsQA.store.getState().tracks.length)).toBe(5); + const snapshot = await page.evaluate(() => { + const { store, commandManager } = window.__timelineControlsQA; + return { tracks: store.getState().tracks, undo: commandManager.getUndoStack().length }; + }); + expect(snapshot.undo).toBe(1); + expect(snapshot.tracks.map((track) => track.name)).toEqual(["Collapsed Folder", "Hidden Child", "Collapsed Folder (copy)", "Hidden Child (copy)", "Outside"]); + expect(snapshot.tracks[3].parentFolderId).toBe(snapshot.tracks[2].id); + await page.evaluate(() => window.__timelineControlsQA.store.getState().undo()); + await expect.poll(() => page.evaluate(() => window.__timelineControlsQA.store.getState().tracks.length)).toBe(3); + await page.evaluate(() => window.__timelineControlsQA.store.getState().redo()); + await expect.poll(() => page.evaluate(() => window.__timelineControlsQA.store.getState().tracks)).toEqual(snapshot.tracks); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 0000000..7f0057c --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["vite/client", "node"] + }, + "include": [ + "support/*.ts", + "editor-gesture-regressions.spec.ts", + "timeline-controls.spec.ts", + "recording-zoom.spec.ts", + "release-review-ui.spec.ts", + "audio-settings-switching.spec.ts", + "nam-fuzz-workflow.spec.ts", + "free-suite-design03.spec.ts", + "free-suite-effects.spec.ts", + "free-suite-precision.spec.ts" + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cdee9d2..93a3aca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,14 +23,15 @@ "devDependencies": { "@playwright/test": "^1.62.1", "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.19.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.4", "sharp": "^0.35.3", "tailwindcss": "^4.3.3", "typescript": "^5.2.2", - "vite": "^8.1.5", - "vitest": "^4.1.2" + "vite": "8.1.6", + "vitest": "4.1.12" }, "engines": { "node": "^22.12.0 || >=24.0.0" @@ -776,22 +777,25 @@ } }, "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==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@oxc-project/types": { @@ -1597,9 +1601,9 @@ } }, "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==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "dev": true, "license": "MIT", "optional": true, @@ -1626,12 +1630,22 @@ "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==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1687,16 +1701,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1705,13 +1719,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1732,9 +1746,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -1745,13 +1759,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -1759,14 +1773,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1775,9 +1789,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -1785,13 +1799,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1810,9 +1824,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -2270,9 +2284,9 @@ } }, "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==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -2314,9 +2328,9 @@ "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==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2374,9 +2388,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -2394,9 +2408,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2665,9 +2679,9 @@ } }, "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==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -2694,6 +2708,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "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", @@ -2704,9 +2725,9 @@ } }, "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==", + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz", + "integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==", "dev": true, "license": "MIT", "dependencies": { @@ -2782,19 +2803,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2822,12 +2843,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index c084bf1..61d4cf9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "dev": "vite", "test": "vitest run", "test:e2e": "playwright test", + "test:e2e:types": "tsc -p e2e/tsconfig.json", "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", @@ -32,13 +33,14 @@ "devDependencies": { "@playwright/test": "^1.62.1", "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.19.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.4", "sharp": "^0.35.3", "tailwindcss": "^4.3.3", "typescript": "^5.2.2", - "vite": "^8.1.5", - "vitest": "^4.1.2" + "vite": "8.1.6", + "vitest": "4.1.12" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88e0259..1c4f5d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { import { dispatchGlobalShortcut } from "./utils/globalShortcutDispatcher"; import { toGlobalShortcutPayload } from "./utils/domShortcutEvent"; import { activateShortcutContext } from "./utils/shortcutContext"; +import { trackDragSourceIds } from "./utils/trackCopyPlanning"; import { installModalContextMenuLeakGuard, shouldSuppressWorkspaceContextMenu, @@ -118,6 +119,8 @@ import { PointerSensor, useSensor, useSensors, + type DragStartEvent, + type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, @@ -1410,22 +1413,63 @@ function App() { ); const [activeDragId, setActiveDragId] = useState(null); + const [activeTrackCopy, setActiveTrackCopy] = useState(false); + const trackDragIdsRef = useRef([]); + const trackCopyRef = useRef(false); - const handleDragStart = (event: any) => { - setActiveDragId(event.active.id.toString()); + useEffect(() => { + if (!activeDragId) return; + const onModifier = (event: KeyboardEvent) => { + const copy = event.ctrlKey || event.metaKey; + trackCopyRef.current = copy; + setActiveTrackCopy(copy); + }; + window.addEventListener("keydown", onModifier); + window.addEventListener("keyup", onModifier); + return () => { + window.removeEventListener("keydown", onModifier); + window.removeEventListener("keyup", onModifier); + }; + }, [activeDragId]); + + const handleDragStart = (event: DragStartEvent) => { + const draggedId = event.active.id.toString(); + const state = useDAWStore.getState(); + trackDragIdsRef.current = trackDragSourceIds(state.tracks, state.selectedTrackIds, draggedId); + const modifiers = event.activatorEvent as PointerEvent | KeyboardEvent; + trackCopyRef.current = Boolean(modifiers.ctrlKey || modifiers.metaKey); + setActiveTrackCopy(trackCopyRef.current); + setActiveDragId(draggedId); }; - const handleDragEnd = (event: any) => { + const clearTrackDrag = () => { setActiveDragId(null); + setActiveTrackCopy(false); + trackCopyRef.current = false; + trackDragIdsRef.current = []; + }; + + const handleDragEnd = (event: DragEndEvent) => { + const copy = trackCopyRef.current; + const sourceIds = [...trackDragIdsRef.current]; + clearTrackDrag(); const { active, over } = event; + if (copy && active && over && sourceIds.length > 0) { + void useDAWStore.getState().duplicateSelectedTracks({ + trackIds: sourceIds, + beforeTrackId: active.id !== over.id ? over.id.toString() : undefined, + }); + return; + } + if (active && over && active.id !== over.id) { const { selectedTrackIds, reorderMultipleTracks } = useDAWStore.getState(); // If multiple tracks are selected and the dragged one is among them, move all together if (selectedTrackIds.length > 1 && selectedTrackIds.includes(active.id.toString())) { reorderMultipleTracks(selectedTrackIds, over.id.toString()); } else { - reorderTrack(active.id, over.id); + reorderTrack(active.id.toString(), over.id.toString()); } } }; @@ -1528,6 +1572,7 @@ function App() { collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} + onDragCancel={clearTrackDrag} > t.id)} @@ -1539,7 +1584,7 @@ function App() { ); return (
- + {spacer && (
{activeDragId && (() => { - const { selectedTrackIds } = useDAWStore.getState(); - const isMulti = selectedTrackIds.length > 1 && selectedTrackIds.includes(activeDragId); - const dragTracks = isMulti - ? tracks.filter((t) => selectedTrackIds.includes(t.id)) - : tracks.filter((t) => t.id === activeDragId); + const dragTracks = tracks.filter((track) => trackDragIdsRef.current.includes(track.id)); + const isMulti = dragTracks.length > 1; const trackHeight = useDAWStore.getState().trackHeight; return ( -
+
{dragTracks.map((t) => (
{t.name}
))} - {isMulti && ( + {(isMulti || activeTrackCopy) && (
- {dragTracks.length} tracks + {activeTrackCopy ? "Copy " : ""}{dragTracks.length} track{dragTracks.length === 1 ? "" : "s"}
)}
diff --git a/frontend/src/__tests__/actionCatalogIntegrity.test.ts b/frontend/src/__tests__/actionCatalogIntegrity.test.ts index 1b85115..0256f5f 100644 --- a/frontend/src/__tests__/actionCatalogIntegrity.test.ts +++ b/frontend/src/__tests__/actionCatalogIntegrity.test.ts @@ -304,7 +304,7 @@ describe("action catalog integrity", () => { expect(getRegisteredAction(actionId), actionId).toBeUndefined(); } expect(getRegisteredAction("track.toggleSelectedArm")).toMatchObject({ - shortcut: "R", + shortcut: "Ctrl+Shift+R", shortcutScope: "track_control_panel", }); expect(getDeferredActions().filter((action) => action.reason === "requires_undo_support")) diff --git a/frontend/src/__tests__/approvedEffectEditor.test.tsx b/frontend/src/__tests__/approvedEffectEditor.test.tsx index 25e7fbd..0e73b47 100644 --- a/frontend/src/__tests__/approvedEffectEditor.test.tsx +++ b/frontend/src/__tests__/approvedEffectEditor.test.tsx @@ -36,10 +36,20 @@ describe("approved effect conditional controls", () => { ...Array.from({ length: 12 }, (_, i) => parameter(`noteEnable_${i}`, 1, "notes", "toggle"))]; const html = renderToStaticMarkup(); for (const p of parameters) expect(html).toContain(`data-param="${p.id}"`); - expect(html).toContain("existing pitch editor"); + expect(html).toContain("existing graphical editor"); + expect(html).toContain("Choose Edit Pitch on an audio clip"); expect(html).not.toContain(" p.id === "scale" ? { ...p, value: 0 } : p))} />); - expect(automatic).not.toContain('data-param="noteEnable_'); + // Old state keeps its named-scale restrictions active. Corrected state + // exposes the saved mask but only enables it in Custom mode. + expect(automatic).toContain('data-param="noteEnable_'); + const corrected = renderToStaticMarkup( p.id === "scale" ? { ...p, value: 0 } : p), + parameter("signalPath", 1, "configuration", "enum"), + ])} />); + expect(corrected).toContain('data-param="noteEnable_'); + expect(corrected).toContain('disabled="" data-param="noteEnable_0"'); + expect(corrected).toContain("Choose Custom in Scale"); }); it("shows compressor headroom only for an active original colour model", () => { const parameters = [parameter("model", 1, "model", "enum"), { ...parameter("audioCharacter", 1, "character", "enum"), automatable: false }, parameter("headroom")]; diff --git a/frontend/src/__tests__/atomicShortcutMutations.test.ts b/frontend/src/__tests__/atomicShortcutMutations.test.ts index bb379c5..de4cf03 100644 --- a/frontend/src/__tests__/atomicShortcutMutations.test.ts +++ b/frontend/src/__tests__/atomicShortcutMutations.test.ts @@ -385,8 +385,9 @@ describe("atomic Timeline shortcut mutations", () => { 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 }); + // One common six-second selection span preserves the three-second spacing. + expect(state.tracks[0].clips[1]).toMatchObject({ id: duplicateIds[0], startTime: 7 }); + expect(state.tracks[1].midiClips[1]).toMatchObject({ id: duplicateIds[1], startTime: 10 }); expect(commandManager.getUndoStack()).toHaveLength(1); state.undo(); @@ -873,6 +874,78 @@ describe("atomic selected-track shortcuts", () => { expect(useDAWStore.getState().selectedTrackIds).toEqual(duplicateIds); }); + it("copies explicit track drag sources at the drop row with one stable undo command", async () => { + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + const first = createDefaultTrack("first", "First", "#111", "audio", []); + first.clips = [audioClip("original-audio", { startTime: 2.25, duration: 3.5 })]; + const second = createDefaultTrack("second", "Second", "#222", "midi", [first]); + second.midiClips = [midiClip("original-midi", { startTime: 0.75, events: [ + { timestamp: 0.2, type: "noteOn", note: 64, velocity: 90 }, + { timestamp: 0.5, type: "noteOff", note: 64, velocity: 0 }, + ] })]; + const target = createDefaultTrack("target", "Target", "#333", "audio", [first, second]); + useDAWStore.setState({ tracks: [first, second, target], selectedTrackId: "target", selectedTrackIds: ["target"] }); + const ids = await useDAWStore.getState().duplicateSelectedTracks({ trackIds: ["second", "first"], beforeTrackId: "target" }); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["first", "second", ...ids, "target"]); + const audioCopy = useDAWStore.getState().tracks.find((track) => track.id === ids[0])!.clips[0]; + const midiCopy = useDAWStore.getState().tracks.find((track) => track.id === ids[1])!.midiClips[0]; + expect(audioCopy.startTime).toBe(2.25); + expect(audioCopy.id).not.toBe("original-audio"); + expect(midiCopy.id).not.toBe("original-midi"); + expect(midiCopy.events[0]).not.toBe(second.midiClips[0].events[0]); + expect(midiCopy.events[0]).toMatchObject({ timestamp: 0.2, type: "noteOn", note: 64, velocity: 90 }); + expect(midiCopy.startTime).toBe(0.75); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["first", "second", "target"])); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["target"]); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["first", "second", ...ids, "target"])); + expect(useDAWStore.getState().tracks.find((track) => track.id === ids[0])!.clips[0].id).toBe(audioCopy.id); + }); + + it("copies collapsed folder subtrees after their source and restores the complete hierarchy with Undo/Redo", async () => { + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + const folder = createDefaultTrack("folder", "Folder", "#111", "audio", []); + folder.isFolder = true; + folder.folderCollapsed = true; + const child = createDefaultTrack("child", "Child", "#222", "audio", [folder]); + child.parentFolderId = folder.id; + child.clips = [audioClip("child-clip")]; + const outside = createDefaultTrack("outside", "Outside", "#333", "audio", [folder, child]); + useDAWStore.setState({ tracks: [folder, child, outside], selectedTrackId: folder.id, selectedTrackIds: [folder.id] }); + const ids = await useDAWStore.getState().duplicateSelectedTracks(); + expect(ids).toHaveLength(2); + const snapshot = useDAWStore.getState().tracks; + expect(snapshot.map((track) => track.id)).toEqual([folder.id, child.id, ...ids, outside.id]); + expect(snapshot.find((track) => track.id === ids[1])?.parentFolderId).toBe(ids[0]); + expect(snapshot.find((track) => track.id === ids[1])?.clips[0].id).not.toBe("child-clip"); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([folder.id, child.id, outside.id])); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks).toEqual(snapshot)); + }); + + it("copies a child before its parent as a top-level row and preserves that placement through Undo/Redo", async () => { + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + const folder = createDefaultTrack("folder", "Folder", "#111", "audio", []); + folder.isFolder = true; + const child = createDefaultTrack("child", "Child", "#222", "audio", [folder]); + child.parentFolderId = folder.id; + useDAWStore.setState({ tracks: [folder, child], selectedTrackId: child.id, selectedTrackIds: [child.id] }); + const ids = await useDAWStore.getState().duplicateSelectedTracks({ trackIds: [child.id], beforeTrackId: folder.id }); + expect(useDAWStore.getState().tracks[0]).toMatchObject({ id: ids[0], parentFolderId: undefined }); + expect(useDAWStore.getState().tracks[2].parentFolderId).toBe(folder.id); + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks).toHaveLength(2)); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks[0]).toMatchObject({ id: ids[0], parentFolderId: undefined })); + }); + 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([]); diff --git a/frontend/src/__tests__/audioPerformanceAdvisory.test.ts b/frontend/src/__tests__/audioPerformanceAdvisory.test.ts new file mode 100644 index 0000000..c739d0f --- /dev/null +++ b/frontend/src/__tests__/audioPerformanceAdvisory.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + resolveAudioPerformanceAdvisory, + type AudioPerformanceTelemetry, +} from "../utils/audioPerformanceAdvisory"; + +const base: AudioPerformanceTelemetry = { + blockSize: 480, + sampleRate: 48000, + lastCallbackCounter: 1000, + audioCallbackDeadlineMissCount: 0, + audioDeviceXRunCount: 0, +}; + +describe("audio performance advisory", () => { + it.each([ + "deadlineMisses", + "rejectedTrackJobs", + "cancelledJobs", + "rejectedBatches", + "quarantinedBlocks", + ] as const)("warns after one recent %s even when the outer callback meets its deadline", (counter) => { + const advisory = resolveAudioPerformanceAdvisory({ + ...base, + trackExecution: { [counter]: 1, lastFailureCallbackCounter: 999 }, + }); + + expect(advisory).toMatchObject({ + source: "openstudio", + shouldWarn: true, + deviceXRunCount: 0, + deadlineStatus: { shouldWarn: false }, + trackExecutionStatus: { + [counter]: 1, + hasRecordedFailures: true, + secondsSinceLastFailure: 0.01, + shouldWarn: true, + }, + }); + }); + + it("retains distinct lifetime counts after the recent warning expires", () => { + const advisory = resolveAudioPerformanceAdvisory({ + ...base, + lastCallbackCounter: 2000, + trackExecution: { + deadlineMisses: 2, + rejectedTrackJobs: 3, + cancelledJobs: 4, + rejectedBatches: 1, + quarantinedBlocks: 5, + lastFailureCallbackCounter: 999, + }, + }); + + expect(advisory).toMatchObject({ + source: "none", + shouldWarn: false, + trackExecutionStatus: { + deadlineMisses: 2, + rejectedTrackJobs: 3, + cancelledJobs: 4, + rejectedBatches: 1, + quarantinedBlocks: 5, + hasRecordedFailures: true, + secondsSinceLastFailure: 10.01, + recent: false, + }, + }); + }); + + it("includes the ten-second boundary and clears immediately beyond it", () => { + const telemetry = { + ...base, + lastCallbackCounter: 2000, + trackExecution: { deadlineMisses: 1, lastFailureCallbackCounter: 1000 }, + }; + expect(resolveAudioPerformanceAdvisory(telemetry).shouldWarn).toBe(true); + expect(resolveAudioPerformanceAdvisory({ + ...telemetry, + lastCallbackCounter: 2001, + }).shouldWarn).toBe(false); + }); + + it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1001])( + "keeps incomplete or reset callback evidence diagnostic-only (%s)", + (lastFailureCallbackCounter) => { + expect(resolveAudioPerformanceAdvisory({ + ...base, + trackExecution: { deadlineMisses: 1, lastFailureCallbackCounter }, + })).toMatchObject({ + shouldWarn: false, + trackExecutionStatus: { hasRecordedFailures: true, recent: false }, + }); + }, + ); + + it("ignores invalid failure counts and older backends without worker telemetry", () => { + expect(resolveAudioPerformanceAdvisory(base).shouldWarn).toBe(false); + expect(resolveAudioPerformanceAdvisory(undefined).shouldWarn).toBe(false); + expect(resolveAudioPerformanceAdvisory({ + ...base, + trackExecution: { + deadlineMisses: Number.NaN, + cancelledJobs: Number.POSITIVE_INFINITY, + rejectedTrackJobs: -1, + lastFailureCallbackCounter: 999, + }, + }).trackExecutionStatus).toMatchObject({ + hasRecordedFailures: false, + shouldWarn: false, + }); + }); + + it("keeps device-path xruns separate from recent track execution failures", () => { + expect(resolveAudioPerformanceAdvisory({ + ...base, + audioDeviceXRunCount: 2, + })).toMatchObject({ source: "device", shouldWarn: true }); + expect(resolveAudioPerformanceAdvisory({ + ...base, + audioDeviceXRunCount: 2, + trackExecution: { deadlineMisses: 1, lastFailureCallbackCounter: 999 }, + })).toMatchObject({ source: "both", shouldWarn: true }); + }); + + it("preserves the existing quiet policy for one idle callback-duration miss", () => { + expect(resolveAudioPerformanceAdvisory({ + ...base, + audioCallbackDeadlineMissCount: 1, + audioCallbackDeadlineMissBurstCount: 1, + lastAudioCallbackDeadlineMissCounter: 999, + })).toMatchObject({ + source: "none", + shouldWarn: false, + deadlineStatus: { recent: true, shouldWarn: false }, + }); + }); +}); diff --git a/frontend/src/__tests__/audioSettingsSession.test.ts b/frontend/src/__tests__/audioSettingsSession.test.ts index 531e8de..f2296d2 100644 --- a/frontend/src/__tests__/audioSettingsSession.test.ts +++ b/frontend/src/__tests__/audioSettingsSession.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { AudioSettingsSession } from "../utils/audioSettingsSession"; -import type { AudioDeviceSetupResponse } from "../services/NativeBridge"; +import { AudioSettingsSession, audioDeviceAcceptanceNotice } from "../utils/audioSettingsSession"; +import type { AudioDeviceDraft, AudioDeviceSetupResponse } from "../services/NativeBridge"; const setup = (outputDevice = "Interface", sampleRate = 44100, bufferSize = 64): AudioDeviceSetupResponse => ({ current: { audioDeviceType: "Windows Audio", inputDevice: "Microphone", outputDevice, sampleRate, bufferSize }, @@ -8,6 +8,11 @@ const setup = (outputDevice = "Interface", sampleRate = 44100, bufferSize = 64): sampleRates: outputDevice === "Speakers" ? [48000] : [44100, 48000], bufferSizes: outputDevice === "Speakers" ? [480, 960] : [64, 128], }); +const asioSetup = (bufferSize = 8): AudioDeviceSetupResponse => ({ + ...setup("Audient", 48000, bufferSize), + current: { audioDeviceType: "ASIO", inputDevice: "Audient", outputDevice: "Audient", sampleRate: 48000, bufferSize }, + bufferSizes: [8, 16, 32], +}); function deferred() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; @@ -16,6 +21,12 @@ function deferred() { } describe("audio settings pending configuration", () => { + it("reports accepted scalar changes while leaving automatic or identical requests quiet", () => { + expect(audioDeviceAcceptanceNotice(asioSetup(16).current, asioSetup(8).current)).toBe("Requested 16 samples; the driver accepted 8 samples."); + expect(audioDeviceAcceptanceNotice(setup("Interface", 44100, 128).current, setup("Interface", 48000, 64).current)).toBe("Requested 128 samples; the driver accepted 64 samples. Requested 44100 Hz; the driver accepted 48000 Hz."); + expect(audioDeviceAcceptanceNotice(setup().current, setup().current)).toBeNull(); + expect(audioDeviceAcceptanceNotice(setup("Interface", 0, 0).current, setup().current)).toBeNull(); + }); it("updates dependent options and selections without applying, preserving the active setup", async () => { const query = vi.fn(async () => ({ ...setup("Speakers", 48000, 480), adjustments: ["Sample rate adjusted to 48000."] })); const session = new AudioSettingsSession(query); @@ -84,4 +95,56 @@ describe("audio settings pending configuration", () => { expect(session.getSnapshot().error).toBe("No common sample rates"); expect(session.getSnapshot().setup?.bufferSizes).toEqual([]); }); + + it("retains a supported pending ASIO buffer without changing the applied device", async () => { + const active = asioSetup(); + const query = vi.fn(async (draft: AudioDeviceDraft) => ({ ...active, current: { ...active.current, ...draft } })); + const session = new AudioSettingsSession(query); + await session.load(async () => active); + await session.select({ bufferSize: 16 }); + expect(query).toHaveBeenCalledWith({ ...active.current, bufferSize: 16 }); + expect(session.getSnapshot().setup?.current.bufferSize).toBe(16); + expect(session.getSnapshot().applied?.current.bufferSize).toBe(8); + expect(session.getSnapshot().error).toBeNull(); + }); + + it("keeps the ASIO draft on discovery failure and retries it with unavailable capabilities intact", async () => { + const active = asioSetup(); + const query = vi.fn() + .mockResolvedValueOnce({ ...active, bufferSizes: [], sampleRates: [], capabilityStatus: "unavailable", error: "Driver could not report its settings." }) + .mockResolvedValueOnce({ ...active, current: { ...active.current, bufferSize: 16 } }) + .mockResolvedValueOnce({ ...active, current: { ...active.current, bufferSize: 32 } }); + const session = new AudioSettingsSession(query); + await session.load(async () => active); + await session.select({ bufferSize: 16 }); + expect(session.getSnapshot()).toMatchObject({ + error: "Driver could not report its settings.", resolving: false, + setup: { current: { bufferSize: 16 }, bufferSizes: [], sampleRates: [], capabilityStatus: "unavailable" }, + applied: { current: { bufferSize: 8 } }, + }); + await session.select({}); + expect(query).toHaveBeenNthCalledWith(2, { ...active.current, bufferSize: 16 }); + expect(session.getSnapshot().setup?.current.bufferSize).toBe(16); + expect(session.getSnapshot().error).toBeNull(); + await session.select({ bufferSize: 32 }); + expect(session.getSnapshot().setup?.current.bufferSize).toBe(32); + expect(session.getSnapshot().applied?.current.bufferSize).toBe(8); + }); + + it("does not let a late failed buffer query replace a newer successful choice", async () => { + const first = deferred(); + const second = deferred(); + const active = asioSetup(); + const query = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const session = new AudioSettingsSession(query); + await session.load(async () => active); + const pending16 = session.select({ bufferSize: 16 }); + const pending32 = session.select({ bufferSize: 32 }); + second.resolve({ ...active, current: { ...active.current, bufferSize: 32 } }); + await pending32; + first.resolve({ ...active, bufferSizes: [], error: "Old inspection failed." }); + await pending16; + expect(session.getSnapshot().setup?.current.bufferSize).toBe(32); + expect(session.getSnapshot().error).toBeNull(); + }); }); diff --git a/frontend/src/__tests__/builtInPluginPanel.test.tsx b/frontend/src/__tests__/builtInPluginPanel.test.tsx index 3806875..96aa594 100644 --- a/frontend/src/__tests__/builtInPluginPanel.test.tsx +++ b/frontend/src/__tests__/builtInPluginPanel.test.tsx @@ -1,5 +1,5 @@ import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { BuiltInPluginPanel, BuiltInParamControl, @@ -14,7 +14,11 @@ import { shouldReadBackAfterParamWrite, stepForParam, } from "../components/BuiltInPluginPanel"; -import type { BuiltInParamDescriptor, BuiltInPluginSchema } from "../services/NativeBridge"; +import { nativeBridge, type BuiltInParamDescriptor, type BuiltInPluginSchema } from "../services/NativeBridge"; +import { SuiteParameter } from "../components/builtin/SuiteParameter"; +import { reverbControlLayout } from "../utils/reverbControlLayout"; +import { ReverbEditor } from "../components/builtin/ReverbEditor"; +import { capturePluginState, eqValues } from "../components/builtin/EQToolbar"; function param(overrides: Partial): BuiltInParamDescriptor { return { @@ -149,6 +153,8 @@ const panelSchemas = [ continuous("decayTime", "Decay", 2.2, 0.1, 20, "space", "s"), continuous("wetLevel", "Wet", 0.33, 0, 1, "mix"), continuous("dryLevel", "Dry", 0.7, 0, 1, "mix"), + continuous("outputTrimDb", "Output", 0, -60, 12, "output", "dB"), + { ...choice("reverbReturnMode", "Return level", 1, ["Original", "Calibrated"], "character"), defaultValue: 1 }, ]), schemaWithParams("OpenStudio Chorus", "Modulation", [ choice("mode", "Mode", 0, ["Chorus", "Flanger", "Phaser"], "modulation"), @@ -473,6 +479,97 @@ describe("BuiltInPluginPanel schema model", () => { expect(primaryParamIdsForKind("dynamics", limiter)).toEqual(["threshold", "ceiling", "lookaheadMs", "releaseMs"]); }); + it("keeps Reverb output trim editable while Send disables only the insert balance", () => { + const reverb = schemaWithParams("OpenStudio Reverb", "Reverb", [ + choice("reverbTypeAll", "Reverb type", 0, ["Room", "Hall"], "space"), + toggle("sendMode", "Send", 1, "routing"), + continuous("dryLevel", "Dry", 0, 0, 1, "mix"), + continuous("wetLevel", "Wet", 1, 0, 1, "mix"), + param({ id: "outputTrimDb", label: "Output", value: -6, min: -60, max: 12, defaultValue: 0, unit: "dB" }), + ]); + reverb.pluginId = "reverb"; + const html = renderToStaticMarkup( {}} onRedo={() => {}} onChange={() => {}} onGestureStart={() => {}} onGestureEnd={() => {}} + onApplyState={async () => true} onApplyValues={async () => true} onRecallPreset={async () => true} onFlush={async () => true} />); + + // Output is a sibling of the inert balance, rather than a disabled child. + expect(html).toMatch(/class="reverb-balance[^\"]*" inert="" aria-disabled="true"/); + expect(html).toMatch(/data-param="wetLevel"[\s\S]*?<\/div><\/div>
{ + const reverb = schemaWithParams("OpenStudio Reverb", "Reverb", [ + param({ id: "outputTrimDb", label: "Output", value: -9.125, min: -60, max: 12, defaultValue: 0, unit: "dB" }), + choice("reverbCharacterMode", "Processing", 1, ["Original", "Refined"], "character"), + { ...choice("reverbReturnMode", "Return level", 0, ["Original", "Calibrated"], "character"), defaultValue: 1 }, + ]); + expect(eqValues(reverb)).toEqual({ outputTrimDb: -9.125, reverbCharacterMode: 1, reverbReturnMode: 0 }); + const fullState = "native-state-with-output-processing-return-level-and-all-reverb-banks"; + const read = vi.spyOn(nativeBridge, "getBuiltInPluginState").mockResolvedValue({ + name: reverb.name, fullState, values: { outputTrimDb: -9.125, reverbCharacterMode: 1, reverbReturnMode: 0 }, + }); + try { + const address = { chain: "track" as const, trackId: "reverb-output", fxIndex: 0 }; + expect(JSON.parse(await capturePluginState(address))).toEqual({ name: reverb.name, fullState }); + expect(read).toHaveBeenCalledWith(address); + } finally { read.mockRestore(); } + }); + + it.each([0, 2, 4, 7, 19, 36, 40])("assigns Return level to Setup for type %i and preserves its saved choice", type => { + const reverb = schemaWithParams("OpenStudio Reverb", "Reverb", [ + choice("reverbTypeAll", "Reverb type", type, Array.from({ length: 41 }, (_, index) => `Type ${index}`), "space"), + { ...choice("reverbReturnMode", "Return level", 1, ["Original", "Calibrated"], "character"), defaultValue: 1 }, + continuous("wetLevel", "Wet", .33, 0, 1, "mix"), continuous("dryLevel", "Dry", .7, 0, 1, "mix"), + ]); + reverb.pluginId = "reverb"; + const setup = reverbControlLayout(reverb).pages.find(page => page.id === "setup"); + const control = setup?.controls.find(item => item.id === "reverbReturnMode"); + expect(control).toBeDefined(); + const saved = reverb.parameters.find(item => item.id === control!.id)!; + const html = renderToStaticMarkup( {}} />); + expect(html).toContain('data-param="reverbReturnMode"'); + expect(html).toContain('aria-label="Return level"'); + expect(html).toContain('aria-description="Calibrated applies fixed wet-return trims'); + expect(html).toContain(''); + expect(reverb.parameters.find(item => item.id === "wetLevel")?.value).toBe(.33); + expect(reverb.parameters.find(item => item.id === "dryLevel")?.value).toBe(.7); + }); + + it("displays the Original return choice from old-state readback instead of the fresh Calibrated default", () => { + const reverb = schemaWithParams("OpenStudio Reverb", "Reverb", [ + choice("reverbTypeAll", "Reverb type", 0, ["Room", "Hall"], "space"), + { ...choice("reverbReturnMode", "Return level", 0, ["Original", "Calibrated"], "character"), defaultValue: 1 }, + ]); + reverb.pluginId = "reverb"; + const setup = reverbControlLayout(reverb).pages.find(page => page.id === "setup"); + const control = setup?.controls.find(item => item.id === "reverbReturnMode"); + expect(control).toBeDefined(); + const saved = reverb.parameters.find(item => item.id === control!.id)!; + const html = renderToStaticMarkup( {}} />); + expect(html).toContain(''); + }); + + it("renders the saved global Processing choice with Original and Refined options", () => { + const reverb = schemaWithParams("OpenStudio Reverb", "Reverb", [ + choice("reverbTypeAll", "Reverb type", 19, Array.from({ length: 41 }, (_, type) => `Type ${type}`), "space"), + choice("reverbCharacterMode", "Processing", 1, ["Original", "Refined"], "character"), + ]); + reverb.pluginId = "reverb"; + const html = renderToStaticMarkup( {}} onRedo={() => {}} onChange={() => {}} onGestureStart={() => {}} onGestureEnd={() => {}} + onApplyState={async () => true} onApplyValues={async () => true} onRecallPreset={async () => true} onFlush={async () => true} />); + expect(html).toContain('data-param="reverbCharacterMode"'); + expect(html).toContain('aria-label="Processing"'); + expect(html).toContain(''); + expect(html).toContain("The choice is shared across types."); + }); + it("keeps grouped controls in plugin-specific order", () => { expect(groupLabel("eqBand")).toBe("Bands"); expect(groupSortWeight("eq", "eqBand")).toBeLessThan(groupSortWeight("eq", "output")); diff --git a/frontend/src/__tests__/builtInPluginParamHistory.test.ts b/frontend/src/__tests__/builtInPluginParamHistory.test.ts index a67d7fe..553c0e4 100644 --- a/frontend/src/__tests__/builtInPluginParamHistory.test.ts +++ b/frontend/src/__tests__/builtInPluginParamHistory.test.ts @@ -13,6 +13,60 @@ function deferred() { } describe("built-in plugin parameter history", () => { + it("undoes Spread scheduling and a continuous broadband-body edit independently of balance", async () => { + const history = new BuiltInPluginParamHistory("reverb-spread-body-window"); + const values: Record = { shimmerScheduling: 0, ambBody: 0, wetLevel: .33, dryLevel: .7, outputTrimDb: -3 }; + history.begin("shimmerScheduling", "Pitch scheduling", 0); + values.shimmerScheduling = 1; history.update("shimmerScheduling", 1); + await history.commit(async () => true); + history.begin("ambBody", "Broadband body", 0); + for (const value of [.1, .25, .4, .5]) { values.ambBody = value; history.update("ambBody", value); } + await history.commit(async () => true); + const replay: BuiltInPluginHistoryReplay = async (entry, direction) => { + for (const change of entry.changes) values[change.paramId] = change[direction]; + return true; + }; + expect(history.getUndoEntries()).toHaveLength(2); + await expect(history.undo(async () => true, replay)).resolves.toBe("applied"); + expect(values).toEqual({ shimmerScheduling: 1, ambBody: 0, wetLevel: .33, dryLevel: .7, outputTrimDb: -3 }); + await expect(history.undo(async () => true, replay)).resolves.toBe("applied"); + expect(values.shimmerScheduling).toBe(0); + await history.redo(async () => true, replay); await history.redo(async () => true, replay); + expect(values).toEqual({ shimmerScheduling: 1, ambBody: .5, wetLevel: .33, dryLevel: .7, outputTrimDb: -3 }); + }); + it("undoes shared Return level without changing Processing or the independent wet/dry balance", async () => { + const history = new BuiltInPluginParamHistory("reverb-return-window"); + const values: Record = { reverbReturnMode: 1, reverbCharacterMode: 0, outputTrimDb: -6, wetLevel: .33, dryLevel: .7 }; + history.begin("reverbReturnMode", "Return level", values.reverbReturnMode); + values.reverbReturnMode = 0; history.update("reverbReturnMode", 0); + await history.commit(async () => true); + const replay: BuiltInPluginHistoryReplay = async (entry, direction) => { + for (const change of entry.changes) values[change.paramId] = change[direction]; + return true; + }; + await expect(history.undo(async () => true, replay)).resolves.toBe("applied"); + expect(values).toEqual({ reverbReturnMode: 1, reverbCharacterMode: 0, outputTrimDb: -6, wetLevel: .33, dryLevel: .7 }); + await expect(history.redo(async () => true, replay)).resolves.toBe("applied"); + expect(values).toEqual({ reverbReturnMode: 0, reverbCharacterMode: 0, outputTrimDb: -6, wetLevel: .33, dryLevel: .7 }); + expect(history.getUndoEntries()).toHaveLength(1); + }); + it("replays the shared Reverb Processing choice as one edit without changing Output", async () => { + const history = new BuiltInPluginParamHistory("reverb-refined-window"); + const values: Record = { reverbCharacterMode: 0, outputTrimDb: -6 }; + history.begin("reverbCharacterMode", "Processing", values.reverbCharacterMode); + values.reverbCharacterMode = 1; + history.update("reverbCharacterMode", 1); + await history.commit(async () => true); + const replay: BuiltInPluginHistoryReplay = async (entry, direction) => { + for (const change of entry.changes) values[change.paramId] = change[direction]; + return true; + }; + await expect(history.undo(async () => true, replay)).resolves.toBe("applied"); + expect(values).toEqual({ reverbCharacterMode: 0, outputTrimDb: -6 }); + await expect(history.redo(async () => true, replay)).resolves.toBe("applied"); + expect(values).toEqual({ reverbCharacterMode: 1, outputTrimDb: -6 }); + expect(history.getUndoEntries()).toHaveLength(1); + }); it("records a knob drag as one undo step and replays redo", async () => { const history = new BuiltInPluginParamHistory("nam-window-a"); const flush = vi.fn(async () => true); @@ -163,6 +217,21 @@ describe("built-in plugin parameter history", () => { describe("opaque plugin history", () => { + it("restores all type memories and portable assets together after collection recall", async () => { + const history = new BuiltInPluginParamHistory("reverb-collection-window"); + const before = JSON.stringify({ fullState: "original-native-state-with-user-IR-and-41-banks", presetName: "User room" }); + const after = JSON.stringify({ fullState: "voiced-native-state-with-generated-IR-and-41-authored-banks", presetName: "Factory - Voiced collection" }); + let current = after; + history.recordState(before, after); + const replay: BuiltInPluginHistoryReplay = async (entry, direction) => { + current = entry.state![direction]; return true; + }; + await expect(history.undo(async () => true, replay)).resolves.toBe("applied"); + expect(current).toBe(before); + await expect(history.redo(async () => true, replay)).resolves.toBe("applied"); + expect(current).toBe(after); + expect(history.getUndoEntries()).toHaveLength(1); + }); it("keeps assets and hidden engine state together through undo/redo", async () => { const history = new BuiltInPluginParamHistory("reverb-one"); const before = JSON.stringify({ fullState: "embedded-IR-a", engine: 1 }); @@ -184,3 +253,19 @@ describe("opaque plugin history", () => { expect(second.canUndo()).toBe(false); }); }); + + +describe("signal-path snapshot history", () => { + it("restores the mode and hidden state together on Undo and Redo", async () => { + const history = new BuiltInPluginParamHistory("signal-mode"); + const before = JSON.stringify({ fullState: "legacy-payload", values: { signalPath: 0, noteEnable_0: 1 } }); + const after = JSON.stringify({ fullState: "corrected-payload", values: { signalPath: 1, noteEnable_0: 1 } }); + history.recordState(before, after); + let state = after; + const replay: BuiltInPluginHistoryReplay = async (entry, direction) => { state = entry.state![direction]; return true; }; + await expect(history.undo(async () => true, replay)).resolves.toBe("applied"); + expect(state).toBe(before); + await expect(history.redo(async () => true, replay)).resolves.toBe("applied"); + expect(state).toBe(after); + }); +}); diff --git a/frontend/src/__tests__/editorShortcutRegistry.test.ts b/frontend/src/__tests__/editorShortcutRegistry.test.ts index fec55dd..7c3f3ba 100644 --- a/frontend/src/__tests__/editorShortcutRegistry.test.ts +++ b/frontend/src/__tests__/editorShortcutRegistry.test.ts @@ -138,7 +138,7 @@ describe("editor shortcut registry", () => { ["pitch.moveUp", "Up"], ["pitch.moveUpFine", "Shift+Up"], ["pitch.mergeSelectedNotes", "Ctrl+J"], - ["pitch.tool.select", "1"], + ["pitch.tool.select", "V"], ["pitch.tool.split", "6"], ["pitch.closeEditor", "Esc"], ]; diff --git a/frontend/src/__tests__/externalURLSafety.test.ts b/frontend/src/__tests__/externalURLSafety.test.ts index 56209b9..c93e658 100644 --- a/frontend/src/__tests__/externalURLSafety.test.ts +++ b/frontend/src/__tests__/externalURLSafety.test.ts @@ -1,4 +1,3 @@ -// @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 { diff --git a/frontend/src/__tests__/freeSuiteFeatureNavigation.test.tsx b/frontend/src/__tests__/freeSuiteFeatureNavigation.test.tsx new file mode 100644 index 0000000..a0d7301 --- /dev/null +++ b/frontend/src/__tests__/freeSuiteFeatureNavigation.test.tsx @@ -0,0 +1,167 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { BuiltInParamDescriptor, BuiltInPluginSchema } from "../services/NativeBridge"; +import type { ApprovedEffectEditorProps } from "../components/builtin/ApprovedEffectEditor"; +import type { PluginFeatureRevealRequest } from "../utils/pluginFeatureNavigation"; +import { pluginSearchFeatures } from "../components/builtin/PluginFeatureFinder"; +import { GateEditor } from "../components/builtin/GateEditor"; +import { CompressorEditor } from "../components/builtin/CompressorEditor"; +import { LimiterEditor } from "../components/builtin/LimiterEditor"; + +const navigation = vi.hoisted(() => ({ request: null as PluginFeatureRevealRequest | null })); +// SSR cannot dispatch browser events. Deliver one navigation request to the +// editor's real reveal callback so its normally collapsed detail page renders. +// Browser focus, scrolling and disclosure geometry remain browser-test duties. +vi.mock("../utils/pluginFeatureNavigation", async importOriginal => { + const original = await importOriginal(); + return { + ...original, + usePluginFeatureReveal: (...args: Parameters) => { + original.usePluginFeatureReveal(...args); + const [, pluginId, reveal] = args; + if (navigation.request?.pluginId === pluginId) { + const request = navigation.request; + navigation.request = null; + reveal(request); + } + }, + }; +}); + +function parameter(id: string, value = 0, type: BuiltInParamDescriptor["type"] = "continuous", + graphRole = "controls"): BuiltInParamDescriptor { + return { id, label: id, value, defaultValue: 0, min: 0, max: 100, type, graphRole, automatable: true, + ...(type === "enum" ? { enumOptions: [{ value: 0, label: "Classic" }, { value: 1, label: "Transparent" }, { value: 6, label: "Edge" }] } : {}) }; +} + +function editorProps(pluginId: string, parameters: BuiltInParamDescriptor[]): ApprovedEffectEditorProps { + const schema: BuiltInPluginSchema = { schemaVersion: 1, pluginId, name: `OpenStudio ${pluginId}`, + category: "Effect", chain: "track", fxIndex: 0, parameters }; + return { schema, address: { trackId: "track", chain: "track", fxIndex: 0 }, canUndo: false, canRedo: false, + onChange: vi.fn(), onGestureStart: vi.fn(), onGestureEnd: vi.fn(), onUndo: vi.fn(), onRedo: vi.fn(), + onApplyValues: vi.fn(async () => true), onApplyState: vi.fn(async () => true), + onRecallPreset: vi.fn(async () => true), onFlush: vi.fn(async () => true) }; +} + +function tagWithLabel(html: string, tag: string, label: string): string { + const element = html.match(new RegExp(`<${tag}\\b[^>]*>`, "g"))?.find(candidate => candidate.includes(`aria-label="${label}"`)); + expect(element, `${tag} named ${label}`).toBeDefined(); + return element ?? ""; +} + +function expectRetained(props: ApprovedEffectEditorProps, before: string) { + expect(JSON.stringify(props.schema)).toBe(before); + expect(props.onChange).not.toHaveBeenCalled(); + expect(props.onApplyValues).not.toHaveBeenCalled(); + expect(props.onApplyState).not.toHaveBeenCalled(); +} + +beforeEach(() => { navigation.request = null; }); + +describe("free-suite feature discovery", () => { + it("excludes retained banks, bypass, signal compatibility and superseded controls", () => { + const props = editorProps("limiter", [parameter("threshold"), parameter("limitingStyle", 0, "enum"), + parameter("limitingStyleAll", 1, "enum"), parameter("savedStyle.threshold", 12, "continuous", "stateBank"), + parameter("bypass", 0, "toggle"), parameter("signalPath", 1, "enum")]); + const before = JSON.stringify(props.schema); + const features = pluginSearchFeatures(props.schema); + expect(features.map(feature => feature.parameterId)).toEqual(["threshold", "limitingStyleAll"]); + expectRetained(props, before); + }); + + it("keeps enum choice names searchable and legacy-only schemas usable", () => { + const schema = editorProps("limiter", [parameter("limitingStyle", 0, "enum")]).schema; + const features = pluginSearchFeatures(schema); + expect(features).toHaveLength(1); + expect(features[0].parameterId).toBe("limitingStyle"); + for (const query of ["Classic", "Transparent", "Edge"]) + expect(`${features[0].label} ${features[0].detail}`.toLowerCase()).toContain(query.toLowerCase()); + }); + + it("collapses every generation of expanded Reverb selectors", () => { + const schema = editorProps("reverb", ["algorithm", "reverbType", "reverbTypeExpanded", "reverbTypeExtended", "reverbTypeAll"] + .map(id => parameter(id, 0, "enum"))).schema; + expect(pluginSearchFeatures(schema).filter(feature => feature.parameterId).map(feature => feature.parameterId)).toEqual(["reverbTypeAll"]); + }); + + it("collapses intermediate Synth destination aliases when the full range exists", () => { + const schema = editorProps("synth", ["matrix1Target", "matrix1TargetExpanded", "matrix1TargetFull"] + .map(id => parameter(id, 0, "enum"))).schema; + expect(pluginSearchFeatures(schema).filter(feature => feature.parameterId).map(feature => feature.parameterId)).toEqual(["matrix1TargetFull"]); + }); + + it("exposes alignment workflows under the native utility plugin id", () => { + const features = pluginSearchFeatures(editorProps("utility", []).schema); + expect(features.map(feature => [feature.id, feature.workflowId])).toEqual([ + ["workflow:alignment", "alignment"], ["workflow:spectral", "spectral"], + ]); + expect(features.every(feature => feature.parameterId === undefined)).toBe(true); + }); +}); + +describe("inactive dynamics controls retain their settings", () => { + it("disables Gate-only Hold and Hysteresis in Expander while retaining active timing", () => { + const parameters = [parameter("expansionMode", 1, "enum"), parameter("hysteresis", 8), parameter("holdMs", 57), + parameter("attackMs", 3), parameter("releaseMs", 119), parameter("transientResponse", 1, "enum")]; + const props = editorProps("gate", parameters), before = JSON.stringify(props.schema); + const html = renderToStaticMarkup(); + expect(tagWithLabel(html, "input", "Hold value")).toContain('disabled=""'); + expect(tagWithLabel(html, "input", "Hold value")).toContain('value="57"'); + expect(tagWithLabel(html, "input", "Hysteresis value")).toContain('disabled=""'); + expect(tagWithLabel(html, "div", "Hysteresis")).toContain('aria-disabled="true"'); + expect(tagWithLabel(html, "input", "Attack value")).not.toContain("disabled"); + expect(tagWithLabel(html, "input", "Release value")).not.toContain("disabled"); + expect(html).not.toContain('data-param="transientResponse"'); + expect(html).toContain("retained for Gate mode"); + expectRetained(props, before); + const gate = renderToStaticMarkup( p.id === "expansionMode" ? { ...p, value: 0 } : p))} />); + expect(tagWithLabel(gate, "input", "Hold value")).not.toContain("disabled"); + expect(tagWithLabel(gate, "input", "Hold value")).toContain('value="57"'); + }); + + it("keeps Clean automatic release and makeup values visible but inactive", () => { + const props = editorProps("compressor", [parameter("model", 1, "enum"), parameter("autoRelease", 1, "toggle"), + parameter("autoMakeup", 1, "toggle"), parameter("release", 78), parameter("makeupGain", 7), parameter("attack", 9)]); + const before = JSON.stringify(props.schema), html = renderToStaticMarkup(); + expect(tagWithLabel(html, "input", "Release · auto value")).toContain('disabled=""'); + expect(tagWithLabel(html, "input", "Release · auto value")).toContain('value="78"'); + expect(tagWithLabel(html, "input", "Makeup · auto value")).toContain('disabled=""'); + expect(tagWithLabel(html, "input", "Attack value")).not.toContain("disabled"); + expectRetained(props, before); + }); + + it("keeps FET Input and Output active with Ratio Off while hiding compression-only timing", () => { + const props = editorProps("compressor", [parameter("model", 2, "enum"), parameter("fetEngine", 1, "enum"), + parameter("fetRatioExtended", 5, "enum"), parameter("fetInput", 27), parameter("fetOutput", 31), + parameter("fetAttack", 3), parameter("fetRelease", 5), parameter("fetRecovery", .4), parameter("fetTilt", .7)]); + const before = JSON.stringify(props.schema), html = renderToStaticMarkup(); + expect(tagWithLabel(html, "input", "Input value")).not.toContain("disabled"); + expect(tagWithLabel(html, "input", "Output value")).not.toContain("disabled"); + for (const id of ["fetAttack", "fetRelease", "fetRecovery", "fetTilt"]) expect(html).not.toContain(`data-param="${id}"`); + expect(html).toContain("Compression timing is inactive"); + expectRetained(props, before); + }); + + it.each([0, 1, 6])("exposes only the applicable Limiter timing controls for style %i", style => { + const props = editorProps("limiter", [parameter("limitingStyleAll", style, "enum"), parameter("lookaheadMs", 4), + parameter("slowAttackMs", 17), parameter("releaseMs", 82), parameter("automaticRelease", 1, "toggle"), + parameter("transientLink", 64), parameter("releaseLink", 39)]); + const before = JSON.stringify(props.schema); + navigation.request = { pluginId: "limiter", parameterId: "slowAttackMs" }; + const html = renderToStaticMarkup(); + expect(tagWithLabel(html, "input", "Lookahead value")).not.toContain("disabled"); + expect(tagWithLabel(html, "input", "Release value")).not.toContain("disabled"); + const transient = tagWithLabel(html, "input", "Transient link value"); + expect(transient.includes('disabled=""')).toBe(style === 0); + if (style === 6) { + for (const id of ["slowAttackMs", "automaticRelease", "releaseLink"]) expect(html).not.toContain(`data-param="${id}"`); + expect(html).toContain("do not apply"); + } else { + expect(tagWithLabel(html, "input", "Attack value").includes('disabled=""')).toBe(style === 0); + expect(tagWithLabel(html, "input", "Attack value")).toContain('value="17"'); + expect(tagWithLabel(html, "button", "Auto release").includes('disabled=""')).toBe(style === 0); + expect(tagWithLabel(html, "input", "Release link value").includes('disabled=""')).toBe(style === 0); + } + expectRetained(props, before); + }); +}); diff --git a/frontend/src/__tests__/instrumentControlLocation.test.ts b/frontend/src/__tests__/instrumentControlLocation.test.ts new file mode 100644 index 0000000..62e7e3f --- /dev/null +++ b/frontend/src/__tests__/instrumentControlLocation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { instrumentControlLocation } from "../components/builtin/instrumentControlLocation"; + +describe("instrument feature navigation", () => { + it("reveals all eight synth matrix routes, including expanded automation aliases", () => { + for (let route = 1; route <= 8; route++) for (const suffix of ["Source", "Target", "TargetFull", "Amount"]) + expect(instrumentControlLocation("synth", `matrix${route}${suffix}`)).toEqual({ page: "Modulation", section: "Matrix", route }); + expect(instrumentControlLocation("synth", "macro4Channel")).toEqual({ page: "Expression", macro: 4 }); + expect(instrumentControlLocation("synth", "macro4")).toEqual({ page: "Modulation", section: "Macros" }); + expect(instrumentControlLocation("synth", "filterReleaseMs")?.page).toBe("Filter envelope"); + }); + it("keeps performance controls discoverable outside the compact primary views", () => { + expect(instrumentControlLocation("piano", "softPedalDepth")?.page).toBe("Performance"); + expect(instrumentControlLocation("piano", "coupledBody")?.page).toBe("Resonance"); + expect(instrumentControlLocation("guitar", "harmonicNode")).toEqual({ page: "Play", section: "Articulation" }); + expect(instrumentControlLocation("guitar", "stringMode")).toEqual({ page: "Play", section: "Performance" }); + }); + it("selects the requested drum mixer group without changing the MIDI input map", () => { + for (let piece = 0; piece < 8; piece++) for (const field of ["Gain", "Tuning", "Pan", "Decay", "Output"]) + expect(instrumentControlLocation("drums", `piece${field}${piece}`)).toEqual({ page: "Kit", section: "Selected piece", piece }); + for (let input = 0; input < 128; input++) + expect(instrumentControlLocation("drums", `noteMap${input}`)).toEqual({ page: "Mapping", mapInput: input }); + }); + it("leaves pinned output controls on the current page", () => { + for (const kind of ["synth", "piano", "guitar", "drums"] as const) expect(instrumentControlLocation(kind, "outputGain")).toBeNull(); + expect(instrumentControlLocation("drums", "stereoWidth")).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/namAmpAssetIntegrity.test.ts b/frontend/src/__tests__/namAmpAssetIntegrity.test.ts index a978007..9e3e218 100644 --- a/frontend/src/__tests__/namAmpAssetIntegrity.test.ts +++ b/frontend/src/__tests__/namAmpAssetIntegrity.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namAmpWrapperAndDriveVoiceLayout.test.ts b/frontend/src/__tests__/namAmpWrapperAndDriveVoiceLayout.test.ts index 36fecab..12a88d1 100644 --- a/frontend/src/__tests__/namAmpWrapperAndDriveVoiceLayout.test.ts +++ b/frontend/src/__tests__/namAmpWrapperAndDriveVoiceLayout.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namApprovedSurfaceImplementationContract.test.ts b/frontend/src/__tests__/namApprovedSurfaceImplementationContract.test.ts index 7e76c72..f1d5502 100644 --- a/frontend/src/__tests__/namApprovedSurfaceImplementationContract.test.ts +++ b/frontend/src/__tests__/namApprovedSurfaceImplementationContract.test.ts @@ -132,11 +132,11 @@ describe("NAM Rack approved-surface implementation contract", () => { 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 }, + compressor: { x: 67, y: 42, w: 156, h: 232 }, + octaver: { x: 233, y: 42, w: 156, h: 232 }, + eqBoost: { x: 399, y: 42, w: 156, h: 232 }, + precisionDrive: { x: 565, y: 42, w: 120, h: 232 }, + distortion: { x: 695, y: 42, w: 156, h: 232 }, }); expect(NAM_AMP_FACEPLATE_LAYOUT.controlY).toBeCloseTo(745 / 10.35, 6); diff --git a/frontend/src/__tests__/namCabRoomConsoleLayout.test.ts b/frontend/src/__tests__/namCabRoomConsoleLayout.test.ts index 8f86a7f..d817b5c 100644 --- a/frontend/src/__tests__/namCabRoomConsoleLayout.test.ts +++ b/frontend/src/__tests__/namCabRoomConsoleLayout.test.ts @@ -1,4 +1,3 @@ -// @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 { diff --git a/frontend/src/__tests__/namCabV2.test.ts b/frontend/src/__tests__/namCabV2.test.ts index f679a5a..2357efd 100644 --- a/frontend/src/__tests__/namCabV2.test.ts +++ b/frontend/src/__tests__/namCabV2.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namCabinetSpace.test.ts b/frontend/src/__tests__/namCabinetSpace.test.ts index 8065105..1154c4f 100644 --- a/frontend/src/__tests__/namCabinetSpace.test.ts +++ b/frontend/src/__tests__/namCabinetSpace.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namCaptureUseFlow.test.ts b/frontend/src/__tests__/namCaptureUseFlow.test.ts index a854f53..a373016 100644 --- a/frontend/src/__tests__/namCaptureUseFlow.test.ts +++ b/frontend/src/__tests__/namCaptureUseFlow.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namCompressorV7Controls.test.ts b/frontend/src/__tests__/namCompressorV7Controls.test.ts index 734aad1..31082b7 100644 --- a/frontend/src/__tests__/namCompressorV7Controls.test.ts +++ b/frontend/src/__tests__/namCompressorV7Controls.test.ts @@ -1,4 +1,3 @@ -// @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 { @@ -188,8 +187,8 @@ describe("NAM Rack Compressor V7 frontend contract", () => { expect(compressor.w).toBe(156); expect(compressor.w).toBe(distortion.w); expect(compressor.h).toBe(distortion.h); - expect(compressor.x).toBe(85); - expect(distortion.x + distortion.w).toBe(833); + expect(compressor.x).toBe(67); + expect(distortion.x + distortion.w).toBe(851); for (let index = 1; index < row.length; index += 1) { expect(row[index].x - (row[index - 1].x + row[index - 1].w)).toBe(10); } diff --git a/frontend/src/__tests__/namCurrentDspMigration.test.ts b/frontend/src/__tests__/namCurrentDspMigration.test.ts index c360b50..d5d7fe0 100644 --- a/frontend/src/__tests__/namCurrentDspMigration.test.ts +++ b/frontend/src/__tests__/namCurrentDspMigration.test.ts @@ -1,4 +1,3 @@ -// @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 { @@ -41,6 +40,21 @@ describe("current-only NAM Rack DSP migration", () => { expect(normalizeNAMEffectsDspVersion(21)).toBeUndefined(); }); + it("retains saved octave controls while migrating old state to the current octave/fuzz route", () => { + const values = { octaverEnabled: 1, octaverDownMix: 0.63, octaverUpMix: 0.21, octaverDirectMix: 0.87 }; + for (const legacyVersion of [1, 10, 19, 20]) { + const migrated = migrateLegacyNAMRackPresetDspState({ + values, + dspState: { namEffectsDspVersion: legacyVersion, reverbEngineVersion: 1 }, + }, { completePreset: true }); + expect(migrated).toMatchObject({ + values: { ...values, fuzzEnabled: 0 }, + dspState: { namEffectsDspVersion: 20, reverbEngineVersion: 5 }, + }); + expect(migrateLegacyNAMRackPresetDspState(migrated, { completePreset: true })).toEqual(migrated); + } + }); + it("migrates a complete legacy tone without retaining obsolete pedal voices", () => { expect(migrateLegacyNAMRackPresetDspState({ schemaVersion: 1, @@ -611,7 +625,9 @@ describe("current-only NAM Rack DSP migration", () => { "reverbEngineVersion.store(\n static_cast(currentReverbEngineVersion)", ); expect(rackSource.slice(octaverStart, octaverEnd)).not.toContain("namEffectsDspVersion.load"); - expect(rackSource.slice(octaverStart, octaverEnd)).toContain("rackPolyOctaver.processBlock(buffer)"); + // Native NAMFuzzRegression covers octave-only, fuzz-only and combined route + // execution and callback partitions. This audit checks migration ownership, + // independently of the processor's buffer-view or excitation arguments. expect(rackSource).not.toContain("resetOctaverState"); expect(rackSource).not.toContain("octaverDetectorGateOpen"); expect(rackSource.slice(processStart, processEnd)).not.toContain("namEffectsDspVersion.load"); diff --git a/frontend/src/__tests__/namDelayVNextControls.test.ts b/frontend/src/__tests__/namDelayVNextControls.test.ts index d8be2de..9930df4 100644 --- a/frontend/src/__tests__/namDelayVNextControls.test.ts +++ b/frontend/src/__tests__/namDelayVNextControls.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namDistortionV5Controls.test.ts b/frontend/src/__tests__/namDistortionV5Controls.test.ts index 0e8cb2e..41c85da 100644 --- a/frontend/src/__tests__/namDistortionV5Controls.test.ts +++ b/frontend/src/__tests__/namDistortionV5Controls.test.ts @@ -1,4 +1,3 @@ -// @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 { diff --git a/frontend/src/__tests__/namEqTitleAlignment.test.ts b/frontend/src/__tests__/namEqTitleAlignment.test.ts index 8fd33f9..65e5145 100644 --- a/frontend/src/__tests__/namEqTitleAlignment.test.ts +++ b/frontend/src/__tests__/namEqTitleAlignment.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namGraphicEqControls.test.ts b/frontend/src/__tests__/namGraphicEqControls.test.ts index 25851a1..6746b37 100644 --- a/frontend/src/__tests__/namGraphicEqControls.test.ts +++ b/frontend/src/__tests__/namGraphicEqControls.test.ts @@ -1,4 +1,3 @@ -// @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 } from "../components/NAMRackMixer"; diff --git a/frontend/src/__tests__/namInputMode.test.ts b/frontend/src/__tests__/namInputMode.test.ts index e567ccd..a14a643 100644 --- a/frontend/src/__tests__/namInputMode.test.ts +++ b/frontend/src/__tests__/namInputMode.test.ts @@ -1,4 +1,3 @@ -// @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 { @@ -141,6 +140,7 @@ describe("NAM Rack automatic input routing contract", () => { expect(resolveNAMRackOctaverPresentation(version)).toMatchObject({ label: "Stereo Poly Octaver", captionPrefix: "Polyphonic stereo", + hardwareTitle: "OCTAVER FUZZ", stereoPolyphonic: true, }); } diff --git a/frontend/src/__tests__/namInstrumentProfile.test.ts b/frontend/src/__tests__/namInstrumentProfile.test.ts index 3f4ff0d..d089f69 100644 --- a/frontend/src/__tests__/namInstrumentProfile.test.ts +++ b/frontend/src/__tests__/namInstrumentProfile.test.ts @@ -1,4 +1,3 @@ -// @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"; @@ -17,6 +16,7 @@ import { namInstrumentLabelsAreCompatible, namInstrumentProfileTagIsCompatible, namStoredPresetMatchesInstrumentProfile, + namStoredPresetProfileMismatch, namPreEqBandLabelsForProfile, namPreEqBandsForProfile, normalizeNAMInstrumentProfile, @@ -29,6 +29,13 @@ import { } from "../utils/namRackPresetTransactions"; describe("NAM Rack instrument profile", () => { + it("flags only explicit canonical/sidecar disagreements without inferring a profile from a name", () => { + expect(namStoredPresetProfileMismatch({ instrumentProfile: "guitar", metadata: { instrumentProfile: "bass" } })).toEqual({ stored: "guitar", labelled: "bass" }); + expect(namStoredPresetProfileMismatch({ instrumentProfile: "bass", metadata: { instrumentProfile: "guitar" } })).toEqual({ stored: "bass", labelled: "guitar" }); + expect(namStoredPresetProfileMismatch({ instrumentProfile: "bass", metadata: { instrumentProfile: "bass" } })).toBeNull(); + expect(namStoredPresetProfileMismatch({ instrumentProfile: "guitar" })).toBeNull(); + expect(namStoredPresetProfileMismatch({ metadata: { instrumentProfile: "bass" } })).toBeNull(); + }); beforeEach(() => { resetNAMExplorerSessionForTests(); }); diff --git a/frontend/src/__tests__/namMultiCaptureLifecycle.test.ts b/frontend/src/__tests__/namMultiCaptureLifecycle.test.ts index a66c1e1..10ce127 100644 --- a/frontend/src/__tests__/namMultiCaptureLifecycle.test.ts +++ b/frontend/src/__tests__/namMultiCaptureLifecycle.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namNativeCapabilityFallback.test.ts b/frontend/src/__tests__/namNativeCapabilityFallback.test.ts index c6c0d4e..4bc6c3f 100644 --- a/frontend/src/__tests__/namNativeCapabilityFallback.test.ts +++ b/frontend/src/__tests__/namNativeCapabilityFallback.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { nativeBridge } from "../services/NativeBridge"; const bridgeInternals = nativeBridge as unknown as { isNative: boolean }; @@ -31,6 +31,23 @@ describe("native capability fallbacks", () => { await expect(nativeBridge.setTrackInputMonitoring("track-a", true)).resolves.toBe(true); }); + it("does not claim a preset was saved when the native JSON writer is unavailable", async () => { + await expect(nativeBridge.saveJsonDocumentToFile("C:/preset.ospreset", '{"name":"Pitch"}')).resolves.toBe(false); + }); + + it("forwards preset JSON to its dedicated endpoint and preserves failures", async () => { + const saveJsonDocumentToFile = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error("disk unavailable")); + const saveProjectToFile = vi.fn(); + (globalThis as { window?: unknown }).window = { __JUCE__: { backend: { saveJsonDocumentToFile, saveProjectToFile } } }; + const path = "C:/preset.ospreset", json = '{"name":"Pitch","scaleMask":2741,"signalPath":1}'; + await expect(nativeBridge.saveJsonDocumentToFile(path, json)).resolves.toBe(true); + expect(saveJsonDocumentToFile).toHaveBeenLastCalledWith(path, json); + await expect(nativeBridge.saveJsonDocumentToFile(path, json)).resolves.toBe(false); + await expect(nativeBridge.saveJsonDocumentToFile(path, json)).rejects.toThrow("disk unavailable"); + expect(saveProjectToFile).not.toHaveBeenCalled(); + }); + it("does not report a mock script success when the native Lua engine is unavailable", async () => { await expect(nativeBridge.executeScript("print('test')")).resolves.toMatchObject({ result: "", error: expect.stringContaining("unavailable") }); await expect(nativeBridge.loadScriptFile("/missing/test.lua")).resolves.toMatchObject({ result: "", error: expect.stringContaining("unavailable") }); diff --git a/frontend/src/__tests__/namOctaverFuzz.test.ts b/frontend/src/__tests__/namOctaverFuzz.test.ts new file mode 100644 index 0000000..31125a0 --- /dev/null +++ b/frontend/src/__tests__/namOctaverFuzz.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { NAM_OCTAVER_FUZZ_FACEPLATE_LAYOUT, NAM_PRE_SIGNAL_LAYOUT, NAM_PRE_LOGICAL_SURFACE } from "../components/NAMRackDesignPort"; +import { NAM_RACK_ADVANCED_CONTROL_IDS, NAM_RACK_ADVANCED_ONLY_CONTROL_IDS, namRackAdvancedStageForCompactModule } from "../components/NAMRackMixer"; +import { migrateLegacyNAMRackPresetDspState, isCurrentNAMRackPresetState } from "../utils/namRackPresetTransactions"; +import { omitNAMNonPortableState } from "../utils/namPortableState"; +import { NAM_FUZZ_DEFAULT_VALUES, normalizeNAMFuzzValues, resolveNAMOctaverFuzzActivity, resolveNAMFuzzCleanEntry } from "../utils/namOctaverFuzz"; + +describe("NAM Octaver Fuzz", () => { + it("normalizes Clean entry text when clamping or rounding leaves the active value unchanged", () => { + const clean = { id: "fuzzCleanMix", label: "Fuzz Clean Blend", type: "continuous", min: 0, max: 1, defaultValue: 0, value: 0 }; + expect(resolveNAMFuzzCleanEntry(clean, "-20")).toEqual({ value: 0, text: "0" }); + expect(resolveNAMFuzzCleanEntry({ ...clean, value: 1 }, "1000")).toEqual({ value: 1, text: "100" }); + expect(resolveNAMFuzzCleanEntry({ ...clean, value: 0.386 }, "38.5")).toEqual({ value: 0.386, text: "38.6" }); + expect(resolveNAMFuzzCleanEntry(clean, "38.4")).toEqual({ value: 0.384, text: "38.4" }); + for (const draft of ["", " ", "not a number", "Infinity"]) { + expect(resolveNAMFuzzCleanEntry({ ...clean, value: 0.384 }, draft)).toEqual({ value: null, text: "38.4" }); + } + }); + it("recognizes fuzz-only operation and gives each combination a clear stage label", () => { + expect(resolveNAMOctaverFuzzActivity(0, 1, 1)).toEqual({ active: true, octaveActive: false, fuzzActive: true, label: "Sustain Fuzz" }); + expect(resolveNAMOctaverFuzzActivity(1, 1, 2).label).toBe("Octave + Gated Fuzz"); + expect(resolveNAMOctaverFuzzActivity(1, 0, 0).label).toBe("Octave"); + expect(resolveNAMOctaverFuzzActivity(0, 0, 0).active).toBe(false); + }); + + it("adds bypassed fuzz to old complete projects, presets, baselines and Compare snapshots without an engine bump", () => { + const legacySnapshot = { values: { octaverEnabled: 1, octaverDownMix: 0.7 }, dspState: { namEffectsDspVersion: 20, reverbEngineVersion: 5 } }; + const migrated = migrateLegacyNAMRackPresetDspState({ + ...legacySnapshot, + uiState: { namPresetBaseline: legacySnapshot, namRackCompare: { snapshots: { A: legacySnapshot, B: legacySnapshot } } }, + }, { completePreset: true }) as any; + for (const snapshot of [migrated, migrated.uiState.namPresetBaseline, migrated.uiState.namRackCompare.snapshots.A, migrated.uiState.namRackCompare.snapshots.B]) { + expect(snapshot.values).toMatchObject(NAM_FUZZ_DEFAULT_VALUES); + expect(snapshot.values.octaverDownMix).toBe(0.7); + expect(snapshot.dspState).toEqual({ namEffectsDspVersion: 20, reverbEngineVersion: 5 }); + expect(isCurrentNAMRackPresetState(snapshot)).toBe(true); + } + }); + + it("round-trips active fuzz through portable state and complete-preset migration", () => { + const values = { fuzzEnabled: 1, fuzzDrive: 0.82, fuzzTone: 0.36, fuzzLevelDb: -4.5, fuzzVoice: 2, fuzzCleanMix: 0.23 }; + const portable = JSON.parse(JSON.stringify({ values, dspState: { namEffectsDspVersion: 20, reverbEngineVersion: 5 } }, omitNAMNonPortableState)); + const migrated = migrateLegacyNAMRackPresetDspState(portable, { completePreset: true }) as any; + expect(migrated.values).toMatchObject(values); + expect(isCurrentNAMRackPresetState(migrated)).toBe(true); + expect(migrateLegacyNAMRackPresetDspState(migrated, { completePreset: true })).toEqual(migrated); + }); + + it("bounds malformed imported fuzz controls and preserves partial-preset intent", () => { + expect(normalizeNAMFuzzValues({ fuzzEnabled: 3, fuzzDrive: -2, fuzzTone: Number.NaN, fuzzLevelDb: 100, fuzzVoice: 1.7, fuzzCleanMix: 2 })) + .toEqual({ fuzzEnabled: 1, fuzzDrive: 0, fuzzTone: 0.5, fuzzLevelDb: 12, fuzzVoice: 2, fuzzCleanMix: 1 }); + const partial = { values: { fuzzDrive: 0.3 } }; + expect(migrateLegacyNAMRackPresetDspState(partial)).toEqual(partial); + }); + + it("exposes every fuzz control on the pedal and retains all parameters in Device Controls", () => { + expect(NAM_RACK_ADVANCED_ONLY_CONTROL_IDS.octaver).toEqual([]); + expect(NAM_RACK_ADVANCED_CONTROL_IDS.octaver).toEqual(expect.arrayContaining(Object.keys(NAM_FUZZ_DEFAULT_VALUES))); + expect(namRackAdvancedStageForCompactModule("octaver")).toBeNull(); + }); + + it("keeps the seven knob/hit envelopes and two independent foot clusters inside painted zones", () => { + const box = NAM_PRE_SIGNAL_LAYOUT.octaver; + const layout = NAM_OCTAVER_FUZZ_FACEPLATE_LAYOUT; + const inside = (zone: { x: number; y: number; w: number; h: number }, x: number, y: number, diameter: number) => { + expect(x - diameter / 2).toBeGreaterThanOrEqual(zone.x); + expect(y - diameter / 2).toBeGreaterThanOrEqual(zone.y); + expect(x + diameter / 2).toBeLessThanOrEqual(zone.x + zone.w); + expect(y + diameter / 2).toBeLessThanOrEqual(zone.y + zone.h); + }; + expect(box.w * layout.knobSize / 100).toBeCloseTo(28, 8); + expect(box.w * layout.cleanKnob.size / 100).toBeCloseTo(22, 8); + inside(layout.safeZones.header, box.w * layout.cleanKnob.x / 100, + box.h * layout.cleanKnob.y / 100, box.w * layout.cleanKnob.hitSize / 100); + inside(layout.safeZones.header, box.w * layout.voiceSelector.x / 100, + box.h * layout.voiceSelector.y / 100, box.w * layout.voiceSelector.hitSize / 100); + for (const row of [layout.topY, layout.lowerY]) for (const column of layout.columns) { + inside(layout.safeZones.knobs, box.w * column / 100, box.h * row / 100, box.w * layout.knobHitSize / 100); + } + for (const column of layout.powerXs) { + inside(layout.safeZones.footer, box.w * column / 100, box.h * layout.footY / 100, box.w * layout.footHitSize / 100); + inside(layout.safeZones.footer, box.w * column / 100, box.h * layout.ledY / 100, 12); + } + expect(box.h).toBe(232); + expect(NAM_PRE_LOGICAL_SURFACE.row.w).toBe(784); + expect(NAM_PRE_LOGICAL_SURFACE.scaleReference).toEqual(NAM_PRE_LOGICAL_SURFACE.row); + expect(NAM_PRE_SIGNAL_LAYOUT.compressor.x).toBe(NAM_PRE_LOGICAL_SURFACE.width - NAM_PRE_SIGNAL_LAYOUT.distortion.x - NAM_PRE_SIGNAL_LAYOUT.distortion.w); + }); +}); diff --git a/frontend/src/__tests__/namPedalCaptureControls.test.ts b/frontend/src/__tests__/namPedalCaptureControls.test.ts index beaeff7..4866c8d 100644 --- a/frontend/src/__tests__/namPedalCaptureControls.test.ts +++ b/frontend/src/__tests__/namPedalCaptureControls.test.ts @@ -1,4 +1,3 @@ -// @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 { diff --git a/frontend/src/__tests__/namPostFxFaceplateLayout.test.ts b/frontend/src/__tests__/namPostFxFaceplateLayout.test.ts index a80b18d..f12697a 100644 --- a/frontend/src/__tests__/namPostFxFaceplateLayout.test.ts +++ b/frontend/src/__tests__/namPostFxFaceplateLayout.test.ts @@ -1,4 +1,3 @@ -// @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 { diff --git a/frontend/src/__tests__/namPrecisionDriveGateControls.test.ts b/frontend/src/__tests__/namPrecisionDriveGateControls.test.ts index 0c3258a..82af3f8 100644 --- a/frontend/src/__tests__/namPrecisionDriveGateControls.test.ts +++ b/frontend/src/__tests__/namPrecisionDriveGateControls.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namPrecisionDriveVoiceV15.test.ts b/frontend/src/__tests__/namPrecisionDriveVoiceV15.test.ts index b1c7ef6..b0b8b15 100644 --- a/frontend/src/__tests__/namPrecisionDriveVoiceV15.test.ts +++ b/frontend/src/__tests__/namPrecisionDriveVoiceV15.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namPresetLibrary.test.ts b/frontend/src/__tests__/namPresetLibrary.test.ts index 6be7fb6..4db5bfe 100644 --- a/frontend/src/__tests__/namPresetLibrary.test.ts +++ b/frontend/src/__tests__/namPresetLibrary.test.ts @@ -1,4 +1,3 @@ -// @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 { diff --git a/frontend/src/__tests__/namPresetManagerTheme.test.ts b/frontend/src/__tests__/namPresetManagerTheme.test.ts index 262bb2e..347575e 100644 --- a/frontend/src/__tests__/namPresetManagerTheme.test.ts +++ b/frontend/src/__tests__/namPresetManagerTheme.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namRackFeedbackPresentationContract.test.ts b/frontend/src/__tests__/namRackFeedbackPresentationContract.test.ts index a8e05c7..81c3568 100644 --- a/frontend/src/__tests__/namRackFeedbackPresentationContract.test.ts +++ b/frontend/src/__tests__/namRackFeedbackPresentationContract.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namRackPresentationFeedback.test.ts b/frontend/src/__tests__/namRackPresentationFeedback.test.ts index 16fdfe7..1d52486 100644 --- a/frontend/src/__tests__/namRackPresentationFeedback.test.ts +++ b/frontend/src/__tests__/namRackPresentationFeedback.test.ts @@ -1,11 +1,71 @@ import { describe, expect, it } from "vitest"; import { + observeNAMOutputProtection, + resolveNAMOutputProtectionCounter, + namOutputProtectionMessage, resolveNAMEconomyQualityWarning, resolveNAMGainStagingWarning, resolveNAMModelIdentityWarning, } from "../utils/namRackPresentationFeedback"; +describe("NAM output protection history", () => { + it("uses a modern final-output zero without relabelling upstream model reductions", () => { + expect(resolveNAMOutputProtectionCounter({ rackOutputSafetyGuardHitCount: 0, + pedalNAMOutputSafetyGuardHitCount: 40, ampNAMOutputSafetyGuardHitCount: 50 })).toEqual({ frames: 0, boundary: "output" }); + expect(resolveNAMOutputProtectionCounter({ rackOutputSafetyGuardHitCount: 12, + pedalNAMOutputSafetyGuardHitCount: 40 })).toEqual({ frames: 12, boundary: "output" }); + }); + + it("uses honest model-stage guidance only when the final counter is absent", () => { + const counter = resolveNAMOutputProtectionCounter({ pedalNAMOutputSafetyGuardHitCount: 40, ampNAMOutputSafetyGuardHitCount: 50 }); + expect(counter).toEqual({ frames: 90, boundary: "models" }); + const baseline = observeNAMOutputProtection(null, "rack", counter.frames, 100, 1000, counter.boundary); + const recent = observeNAMOutputProtection(baseline, "rack", 91, 101, 1100, counter.boundary); + for (const [observation, now] of [[baseline, 1000], [recent, 1100]] as const) { + expect(namOutputProtectionMessage(observation, now)).toContain("NAM model peaks"); + expect(namOutputProtectionMessage(observation, now)).toContain("Lower Input or the Level feeding the model"); + expect(namOutputProtectionMessage(observation, now)).not.toContain("Lower Output"); + } + // Switching telemetry generations is a new baseline, not an event. + const modern = observeNAMOutputProtection(recent, "rack", 100, 102, 1200, "output"); + expect(modern.recentUntilMs).toBe(0); + }); + + it("does not call an existing lifetime count current activity when an editor opens", () => { + const observation = observeNAMOutputProtection(null, "rack-A", 18911, 20000, 1000); + expect(observation.recentUntilMs).toBe(0); + expect(namOutputProtectionMessage(observation, 1000)).toBe("Earlier output peaks were reduced. Lower Output if this happens again."); + }); + + it("warns only for observed increments, then becomes history without another telemetry update", () => { + const baseline = observeNAMOutputProtection(null, "rack-A", 0, 100, 1000); + const active = observeNAMOutputProtection(baseline, "rack-A", 10, 101, 1100); + expect(namOutputProtectionMessage(active, 1100)).toBe("Output peaks reduced recently. Lower Output to avoid extra compression on loud notes."); + const unchanged = observeNAMOutputProtection(active, "rack-A", 10, 200, 3000); + expect(unchanged.recentUntilMs).toBe(4100); + expect(namOutputProtectionMessage(unchanged, 4101)).toContain("Earlier"); + const repeated = observeNAMOutputProtection(unchanged, "rack-A", 11, 201, 4000); + expect(repeated.recentUntilMs).toBe(7000); + }); + + it("resets recent activity on rack changes, counter decreases and native preparation", () => { + const baseline = observeNAMOutputProtection(null, "rack-A", 10, 100, 0); + const active = observeNAMOutputProtection(baseline, "rack-A", 20, 101, 100); + for (const [rack, frames, blocks] of [["rack-B", 21, 102], ["rack-A", 5, 102], ["rack-A", 20, 1]] as const) { + const reset = observeNAMOutputProtection(active, rack, frames, blocks, 200); + expect(reset.recentUntilMs).toBe(0); + expect(namOutputProtectionMessage(reset, 200)).toContain("Earlier"); + } + expect(namOutputProtectionMessage(observeNAMOutputProtection(active, "rack-A", 0, 0, 200), 200)).toBeNull(); + }); + + it("ignores invalid counts rather than creating an invalid warning", () => { + for (const count of [NaN, Infinity, -1]) + expect(namOutputProtectionMessage(observeNAMOutputProtection(null, "rack", count, 0, 0), 0)).toBeNull(); + }); +}); + describe("NAM Rack presentation feedback", () => { it("flags a clear contradiction between the NAM label and embedded gear metadata", () => { expect(resolveNAMModelIdentityWarning({ diff --git a/frontend/src/__tests__/namRackPresetTransactions.test.ts b/frontend/src/__tests__/namRackPresetTransactions.test.ts index 44d0c7d..85523a5 100644 --- a/frontend/src/__tests__/namRackPresetTransactions.test.ts +++ b/frontend/src/__tests__/namRackPresetTransactions.test.ts @@ -1,4 +1,3 @@ -// @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"; @@ -912,7 +911,6 @@ describe("NAM Rack preset transactions", () => { 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( diff --git a/frontend/src/__tests__/namReverbPadControls.test.ts b/frontend/src/__tests__/namReverbPadControls.test.ts index 68d2600..db5d0cb 100644 --- a/frontend/src/__tests__/namReverbPadControls.test.ts +++ b/frontend/src/__tests__/namReverbPadControls.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namReverbV5Controls.test.ts b/frontend/src/__tests__/namReverbV5Controls.test.ts index cdde078..d12d952 100644 --- a/frontend/src/__tests__/namReverbV5Controls.test.ts +++ b/frontend/src/__tests__/namReverbV5Controls.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/namToneCapturePickerAccessibility.test.ts b/frontend/src/__tests__/namToneCapturePickerAccessibility.test.ts index 13d6b2b..7e4985a 100644 --- a/frontend/src/__tests__/namToneCapturePickerAccessibility.test.ts +++ b/frontend/src/__tests__/namToneCapturePickerAccessibility.test.ts @@ -1,4 +1,3 @@ -// @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"; diff --git a/frontend/src/__tests__/nativeBootstrapCalls.test.ts b/frontend/src/__tests__/nativeBootstrapCalls.test.ts index d82aa55..7c959b1 100644 --- a/frontend/src/__tests__/nativeBootstrapCalls.test.ts +++ b/frontend/src/__tests__/nativeBootstrapCalls.test.ts @@ -1,6 +1,4 @@ -// @ts-expect-error Vitest runs in Node; the WebView app omits Node builtin typings. import { readFileSync } from "node:fs"; -// @ts-expect-error Vitest runs in Node; the WebView app omits Node builtin typings. import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; import { nextNativeRequestId } from "../utils/nativeRequestId"; diff --git a/frontend/src/__tests__/pianoRollNoteDrag.test.ts b/frontend/src/__tests__/pianoRollNoteDrag.test.ts new file mode 100644 index 0000000..b4061e9 --- /dev/null +++ b/frontend/src/__tests__/pianoRollNoteDrag.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { commandManager } from "../store/commands"; +import { createDefaultTrack, type MIDIEvent, useDAWStore } from "../store/useDAWStore"; +import { noteIdFor, parseMIDINotePairs, rebuildMIDIEventsForNotes } from "../utils/midiNotes"; +import { + buildMidiNoteDragCopy, + getMidiNoteMoveDeltas, + getMidiNoteShiftSelection, + hasMidiNoteDragStarted, + type MidiNoteDragModifiers, +} from "../utils/pianoRollNoteDrag"; + +const clipId = "drag-clip"; +const trackId = "drag-track"; +const originalState = useDAWStore.getState(); +const events: MIDIEvent[] = [ + { type: "noteOn", timestamp: 0.137, note: 2, velocity: 97, channel: 3, probability: 0.7 }, + { type: "noteOff", timestamp: 0.537, note: 2, velocity: 48, channel: 3 }, + { type: "noteOn", timestamp: 1.273, note: 124, velocity: 76, muted: true }, + { type: "noteOff", timestamp: 1.473, note: 124, velocity: 36, muted: true }, + { type: "pitchBend", timestamp: 0.2, value: 9000, channel: 3 }, +]; +const ids = [noteIdFor(clipId, 0.137, 2), noteIdFor(clipId, 1.273, 124)]; + +function movedEvents(source: MIDIEvent[], noteIds: string[], deltaTime: number, deltaNote: number, modifiers: MidiNoteDragModifiers) { + const selected = parseMIDINotePairs(source, clipId).filter((note) => noteIds.includes(note.id)); + const delta = getMidiNoteMoveDeltas(selected, deltaTime, deltaNote, modifiers); + return rebuildMIDIEventsForNotes(source, clipId, noteIds, (note) => ({ + ...note, + startTime: note.startTime + delta.deltaTime, + noteNumber: note.noteNumber + delta.deltaNote, + })); +} + +beforeEach(() => { + commandManager.clear(); + const track = createDefaultTrack(trackId, "Drag MIDI", "#4361ee", "midi", []); + track.midiClips = [{ id: clipId, name: "Notes", startTime: 0, duration: 4, sourceLength: 4, color: "#4361ee", events }]; + useDAWStore.setState({ + tracks: [track], selectedNoteIds: ids, globalLocked: false, + lockSettings: { ...originalState.lockSettings, items: false }, + isModified: false, canUndo: false, canRedo: false, + }); +}); + +afterEach(() => { + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("MIDI note-body drag constraints", () => { + it.each([{ ctrlKey: true }, { metaKey: true }])("keeps exact pitches while time moves without quantizing for %o", (modifiers) => { + const next = parseMIDINotePairs(movedEvents(events, ids, 0.113, 7, modifiers).events, clipId); + expect(next.map((note) => note.noteNumber)).toEqual([2, 124]); + expect(next[0].startTime).toBeCloseTo(0.25, 12); + expect(next[1].startTime).toBeCloseTo(1.386, 12); + expect(next.map((note) => note.velocity)).toEqual([97, 76]); + expect(next.map((note) => note.releaseVelocity)).toEqual([48, 36]); + expect(next[0].channel).toBe(3); + expect(next[0].probability).toBe(0.7); + expect(next[1].muted).toBe(true); + expect(next.map((note) => note.duration)).toEqual(expect.arrayContaining([expect.closeTo(0.4, 12), expect.closeTo(0.2, 12)])); + }); + + it("preserves exact unsnapped starts on Shift-drag and bounds the entire pitch group", () => { + const next = parseMIDINotePairs(movedEvents(events, ids, 0.7, 20, { shiftKey: true }).events, clipId); + expect(next.map((note) => note.startTime)).toEqual([0.137, 1.273]); + expect(next.map((note) => note.noteNumber)).toEqual([5, 127]); + expect(next[1].noteNumber - next[0].noteNumber).toBe(122); + }); + + it("clamps one shared time delta at zero and one pitch delta at the bottom", () => { + const next = parseMIDINotePairs(movedEvents(events, ids, -3, -20, {}).events, clipId); + expect(next[0].startTime).toBe(0); + expect(next[1].startTime).toBeCloseTo(1.136, 12); + expect(next.map((note) => note.noteNumber)).toEqual([0, 122]); + }); + + it("holds both axes when both constraints are held", () => { + expect(getMidiNoteMoveDeltas(parseMIDINotePairs(events, clipId), 1, 5, { ctrlKey: true, shiftKey: true })) + .toEqual({ deltaTime: 0, deltaNote: 0 }); + }); + + it("distinguishes Shift-click from Shift-drag without removing an existing selected group", () => { + expect(getMidiNoteShiftSelection(ids, ids[0])).toEqual({ clickSelection: [ids[1]], dragSelection: ids }); + expect(getMidiNoteShiftSelection(ids, "new-note")).toEqual({ clickSelection: [...ids, "new-note"], dragSelection: [...ids, "new-note"] }); + expect(hasMidiNoteDragStarted(40, 80, 42, 82)).toBe(false); + expect(hasMidiNoteDragStarted(40, 80, 40, 85)).toBe(true); + }); + + it("commits an entire constrained note movement as one reversible action", () => { + const next = movedEvents(events, ids, 0.211, 12, { ctrlKey: true }).events; + useDAWStore.getState().previewMIDIClipEvents(trackId, clipId, next); + expect(commandManager.getUndoStack()).toHaveLength(0); + useDAWStore.getState().commitMIDIClipEvents(trackId, clipId, events, next, "Move MIDI notes"); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].midiClips[0].events).toEqual([...events].sort((a, b) => a.timestamp - b.timestamp)); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].midiClips[0].events).toEqual(next); + }); + + it("keeps Alt-copy's trailing offset and combines copy plus constrained movement into one Undo", () => { + const copy = buildMidiNoteDragCopy(events, clipId, ids)!; + const copiedNotes = parseMIDINotePairs(copy.events, clipId).filter((note) => copy.noteIds.includes(note.id)); + expect(copiedNotes[0].startTime).toBeCloseTo(1.473, 12); + expect(copiedNotes[1].startTime).toBeCloseTo(2.609, 12); + const next = movedEvents(copy.events, copy.noteIds, 0.123, -8, { ctrlKey: true }).events; + useDAWStore.getState().previewMIDIClipEvents(trackId, clipId, next); + useDAWStore.getState().commitMIDIClipEvents(trackId, clipId, events, next, "Copy MIDI notes"); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(parseMIDINotePairs(next, clipId).map((note) => note.noteNumber)).toEqual([2, 124, 2, 124]); + useDAWStore.getState().undo(); + expect(parseMIDINotePairs(useDAWStore.getState().tracks[0].midiClips[0].events, clipId)).toHaveLength(2); + expect(events).toHaveLength(5); + }); +}); diff --git a/frontend/src/__tests__/pitchCorrectorPresets.test.ts b/frontend/src/__tests__/pitchCorrectorPresets.test.ts new file mode 100644 index 0000000..f23725e --- /dev/null +++ b/frontend/src/__tests__/pitchCorrectorPresets.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FACTORY_PRESETS, parsePitchCorrectorPreset, type PitchCorrectorPreset } from "../components/pitchCorrectorPresets"; +import { applyPitchCorrectorPreset, capturePitchCorrectorPreset, savePitchCorrectorPreset } from "../services/pitchCorrectorPresets"; +import { nativeBridge, type BuiltInPluginAddress, type BuiltInPluginSchema } from "../services/NativeBridge"; + +const address: BuiltInPluginAddress = { trackId: "vocal", chain: "track", fxIndex: 2 }; +const params: PitchCorrectorPreset["params"] = { + key: 2, scale: 15, signalPath: 1, retuneSpeed: 120, correctionStrength: 0.8, + humanize: 20, formantCorrection: true, formantShift: -2, mix: 0.75, transpose: -12, + noteEnables: Array.from({ length: 12 }, (_, index) => index === 0), + midiOutput: true, midiChannel: 7, bypass: false, sensitivity: 0.2, + minFreqParam: 90, maxFreqParam: 1500, detectionSource: 2, humanizeMode: 1, +}; +const nativeValues: Record = { + key: 2, scale: 15, signalPath: 1, retuneSpeed: 120, correctionStrength: 0.8, + humanize: 20, formantCorrection: 1, formantShift: -2, mix: 0.75, transpose: -12, + ...Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`noteEnable_${index}`, index === 0 ? 1 : 0])), + midiOutputEnabled: 1, midiOutputChannel: 7, bypass: 0, sensitivity: 0.2, + minFreqParam: 90, maxFreqParam: 1500, detectionSource: 2, humanizeMode: 1, +}; +const schema = (values = nativeValues): BuiltInPluginSchema => ({ + schemaVersion: 1, pluginId: "pitch", name: "OpenStudio Pitch Correct", category: "Pitch", + chain: "track", fxIndex: 4, instanceId: "pitch-instance", + parameters: Object.entries(values).map(([id, value]) => ({ + id, value, label: id, type: "continuous", min: 0, max: 4000, defaultValue: 0, + })), +}); +const document = (overrides: Record = {}) => ({ name: "D custom", category: "User", params: { ...params, ...overrides } }); + +beforeEach(() => { + vi.spyOn(nativeBridge, "getBuiltInPluginSchema").mockResolvedValue(schema()); + vi.spyOn(nativeBridge, "setBuiltInPluginState").mockResolvedValue(true); + vi.spyOn(nativeBridge, "saveJsonDocumentToFile").mockResolvedValue(true); +}); +afterEach(() => { vi.restoreAllMocks(); }); + +describe("Pitch Correct preset compatibility", () => { + it("imports old user presets in Legacy without rotating their key-relative mask", () => { + const legacy = parsePitchCorrectorPreset(document({ signalPath: undefined })); + expect(legacy.params.signalPath).toBe(0); + expect(legacy.params.key).toBe(2); + expect(legacy.params.noteEnables).toEqual(params.noteEnables); + expect(legacy.params.noteEnables).not.toBe(params.noteEnables); + }); + + it("preserves every supported control in a new file round trip", () => { + expect(parsePitchCorrectorPreset(JSON.parse(JSON.stringify(document())))).toEqual(document()); + }); + + it.each([ + { signalPath: 2 }, { signalPath: "1" }, { signalPath: 0.5 }, { retuneSpeed: Infinity }, + { correctionStrength: -1 }, { noteEnables: [true] }, { noteEnables: Array(12).fill(1) }, + { key: 12 }, { midiOutput: 1 }, { midiChannel: 17 }, { sensitivity: 0.01 }, + { detectionSource: 3 }, { humanizeMode: 2 }, { formantCorrection: "false" }, + ])("rejects malformed state before a native mutation: %j", async invalid => { + expect(() => parsePitchCorrectorPreset(document(invalid))).toThrow(); + expect(nativeBridge.setBuiltInPluginState).not.toHaveBeenCalled(); + }); + + it("accepts the native float representation at a decimal control boundary", () => { + expect(parsePitchCorrectorPreset(document({ sensitivity: Math.fround(0.02) })).params.sensitivity).toBe(0.02); + }); + + it("retains a fractional native MIDI channel value for the processor's own rounding", async () => { + vi.mocked(nativeBridge.getBuiltInPluginSchema).mockResolvedValueOnce(schema({ ...nativeValues, midiOutputChannel: 7.25 })); + const snapshot = await capturePitchCorrectorPreset(address); + await applyPitchCorrectorPreset(snapshot.address, snapshot.params); + expect(nativeBridge.setBuiltInPluginState).toHaveBeenCalledWith(snapshot.address, { values: { ...nativeValues, midiOutputChannel: 7.25 } }); + }); + + it("keeps absent older advanced controls out of the restore patch", async () => { + const legacy = { ...FACTORY_PRESETS[0].params }; + await applyPitchCorrectorPreset(address, legacy); + const patch = vi.mocked(nativeBridge.setBuiltInPluginState).mock.calls[0][1] as { values: Record }; + expect(patch.values.signalPath).toBe(0); + for (const id of ["key", "noteEnable_0", "detectionSource", "humanizeMode", "midiOutputEnabled", "bypass"]) + expect(patch.values).not.toHaveProperty(id); + }); + + it("keeps factory tonal templates usable in either current processing mode", async () => { + for (const signalPath of [0, 1] as const) { + for (const preset of FACTORY_PRESETS) { + await applyPitchCorrectorPreset(address, { signalPath, ...preset.params }); + const patch = vi.mocked(nativeBridge.setBuiltInPluginState).mock.lastCall?.[1] as { values: Record }; + expect(patch.values.signalPath).toBe(signalPath); + expect(patch.values).not.toHaveProperty("noteEnable_0"); + } + } + }); +}); + +describe("Pitch Correct native snapshot transactions", () => { + it("captures the fresh native schema and retains its instance and all controls", async () => { + const snapshot = await capturePitchCorrectorPreset(address); + expect(snapshot).toEqual({ address: { ...address, fxIndex: 4, instanceId: "pitch-instance" }, params }); + await applyPitchCorrectorPreset(snapshot.address, snapshot.params); + expect(nativeBridge.setBuiltInPluginState).toHaveBeenCalledExactlyOnceWith(snapshot.address, { values: nativeValues }); + }); + + it("restores cross-editor A/B processing mode and mask together in both directions", async () => { + const classicWrite = vi.spyOn(nativeBridge, "setPitchCorrectorParam"); + vi.mocked(nativeBridge.getBuiltInPluginSchema).mockResolvedValueOnce(schema({ ...nativeValues, signalPath: 0, detectionSource: 0 })); + const a = await capturePitchCorrectorPreset(address); + // The suite editor selects Corrected and changes advanced controls before B is captured. + const b = await capturePitchCorrectorPreset(a.address); + await applyPitchCorrectorPreset(b.address, a.params); + await applyPitchCorrectorPreset(a.address, b.params); + const patches = vi.mocked(nativeBridge.setBuiltInPluginState).mock.calls.map(([, state]) => state as { values: Record }); + expect(patches).toHaveLength(2); + expect(patches[0].values).toEqual({ ...nativeValues, signalPath: 0, detectionSource: 0 }); + expect(patches[1].values).toEqual(nativeValues); + // Same key/mask now retains its original D (Legacy) or C (Corrected) meaning. + expect(patches.map(patch => patch.values.signalPath === 0 ? patch.values.key : 0)).toEqual([2, 0]); + expect(classicWrite).not.toHaveBeenCalled(); + }); + + it("rejects a different/replaced plugin or incomplete native mask before restore", async () => { + vi.mocked(nativeBridge.getBuiltInPluginSchema).mockResolvedValueOnce({ ...schema(), name: "OpenStudio Reverb" }); + await expect(capturePitchCorrectorPreset(address)).rejects.toThrow("removed or replaced"); + await expect(capturePitchCorrectorPreset({ ...address, instanceId: "removed-instance" })).rejects.toThrow("removed or replaced"); + const missingNote = { ...nativeValues }; + delete missingNote.noteEnable_11; + vi.mocked(nativeBridge.getBuiltInPluginSchema).mockResolvedValueOnce(schema(missingNote)); + await expect(capturePitchCorrectorPreset(address)).rejects.toThrow("noteEnable_11"); + expect(nativeBridge.setBuiltInPluginState).not.toHaveBeenCalled(); + }); + + it("reports rejected or failed native restores without falling back to unsafe scalar calls", async () => { + const classicWrite = vi.spyOn(nativeBridge, "setPitchCorrectorParam"); + vi.mocked(nativeBridge.setBuiltInPluginState).mockResolvedValueOnce(false); + await expect(applyPitchCorrectorPreset(address, params)).rejects.toThrow("could not restore"); + vi.mocked(nativeBridge.setBuiltInPluginState).mockRejectedValueOnce(new Error("instance removed")); + await expect(applyPitchCorrectorPreset(address, params)).rejects.toThrow("instance removed"); + expect(classicWrite).not.toHaveBeenCalled(); + }); + + it("saves a portable JSON preset with its processing mode without using project save", async () => { + const projectSave = vi.spyOn(nativeBridge, "saveProjectToFile"); + await savePitchCorrectorPreset("C:\\Presets\\D custom.ospreset", params); + const [path, json] = vi.mocked(nativeBridge.saveJsonDocumentToFile).mock.calls[0]; + expect(path).toBe("C:\\Presets\\D custom.ospreset"); + expect(parsePitchCorrectorPreset(JSON.parse(json))).toEqual(document()); + expect(projectSave).not.toHaveBeenCalled(); + }); + + it("surfaces false and thrown preset-save failures", async () => { + vi.mocked(nativeBridge.saveJsonDocumentToFile).mockResolvedValueOnce(false); + await expect(savePitchCorrectorPreset("preset.ospreset", params)).rejects.toThrow("could not be saved"); + vi.mocked(nativeBridge.saveJsonDocumentToFile).mockRejectedValueOnce(new Error("disk full")); + await expect(savePitchCorrectorPreset("preset.ospreset", params)).rejects.toThrow("disk full"); + }); +}); diff --git a/frontend/src/__tests__/recordingWaveformAndRenderReporting.test.ts b/frontend/src/__tests__/recordingWaveformAndRenderReporting.test.ts index 76f3fde..e14021b 100644 --- a/frontend/src/__tests__/recordingWaveformAndRenderReporting.test.ts +++ b/frontend/src/__tests__/recordingWaveformAndRenderReporting.test.ts @@ -1,42 +1,7 @@ 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", () => { const declaration = mainComponentSource.match(/const NO_TIMEOUT_FUNCTIONS = \[([^\]]+)\]/)?.[1] ?? ""; diff --git a/frontend/src/__tests__/recordingWaveformPreview.test.ts b/frontend/src/__tests__/recordingWaveformPreview.test.ts new file mode 100644 index 0000000..be784be --- /dev/null +++ b/frontend/src/__tests__/recordingWaveformPreview.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WaveformPeak } from "../services/NativeBridge"; +import { + MAX_RECORDING_PEAK_BASE_ENTRIES, MAX_RECORDING_PREVIEW_PEAKS, RECORDING_PEAK_STRIDE, + planRecordingPreviewWindow, recordingPeakX, RecordingPreviewScheduler, + type RecordingPreviewView, type RecordingWaveformCache, +} from "../utils/recordingWaveformPreview"; + +const peaks = (count: number): WaveformPeak[] => Array.from({ length: count }, () => ({ + channels: [{ min: -0.25, max: 0.5 }], +})); +function view(): RecordingPreviewView { + return { + clips: [{ trackId: "a", startTime: 0 }, { trackId: "b", startTime: 0 }], + sampleRate: 44100, pixelsPerSecond: 100, width: 1280, scrollX: 55000, currentTime: 556, + }; +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +async function settle() { for (let index = 0; index < 8; index++) await Promise.resolve(); } +function fixture() { + let current: RecordingPreviewView | null = view(); + let suspended = false; + let cache: RecordingWaveformCache = new Map(); + const request = vi.fn(async (_track: string, _spp: number, count: number, _start: number) => peaks(count)); + const publish = vi.fn((next: RecordingWaveformCache) => { cache = next; }); + const onError = vi.fn(); + const scheduler = new RecordingPreviewScheduler({ + readView: () => current, isSuspended: () => suspended, request, publish, onError, + }); + return { + scheduler, request, publish, onError, + get cache() { return cache; }, + setView(next: RecordingPreviewView | null) { current = next; }, + suspend(value: boolean) { suspended = value; }, + }; +} + +describe("bounded live recording waveform windows", () => { + it("covers the visible portion of long takes with complete aligned bins at every supported zoom", () => { + for (const sampleRate of [8000, 44100, 48000, 96000, 384000]) + for (const pixelsPerSecond of [1, 34.784, 100, 1000]) + for (const width of [640, 3840, 16384]) { + const current = { ...view(), sampleRate, pixelsPerSecond, width, scrollX: 550 * pixelsPerSecond }; + const window = planRecordingPreviewWindow(current, current.clips[0])!; + expect(window).not.toBeNull(); + expect(window.numPixels).toBeLessThanOrEqual(MAX_RECORDING_PREVIEW_PEAKS); + expect(window.samplesPerPixel % RECORDING_PEAK_STRIDE).toBe(0); + expect(Number.isInteger(Math.log2(window.samplesPerPixel))).toBe(true); + expect(window.startSample % window.samplesPerPixel).toBe(0); + expect(window.startSample).toBeLessThanOrEqual(550 * sampleRate); + const end = window.startSample + window.numPixels * window.samplesPerPixel; + expect(end).toBeLessThanOrEqual(current.currentTime * sampleRate); + const visibleEnd = Math.min(current.currentTime, 550 + width / pixelsPerSecond); + expect(end).toBeGreaterThan(visibleEnd * sampleRate - window.samplesPerPixel); + } + }); + + it("uses clip-relative samples and skips absent or incomplete visible data", () => { + const current = { ...view(), clips: [{ trackId: "a", startTime: 550 }] }; + expect(planRecordingPreviewWindow(current, current.clips[0])?.startSample).toBe(0); + expect(planRecordingPreviewWindow({ ...current, currentTime: 550.001 }, current.clips[0])).toBeNull(); + expect(planRecordingPreviewWindow({ ...current, scrollX: 0 }, current.clips[0])).toBeNull(); + for (const patch of [{ sampleRate: 0 }, { pixelsPerSecond: 0 }, { width: 0 }, { currentTime: NaN }]) + expect(planRecordingPreviewWindow({ ...current, ...patch }, current.clips[0])).toBeNull(); + }); + + it("projects cached samples at the current zoom and scroll without shifting their audio time", () => { + const data = { peaks: peaks(2), recordingStartTime: 10, sampleRate: 48000, samplesPerPixel: 512, startSample: 96000 }; + for (const zoom of [1, 34.784, 100, 1000]) { + const x = recordingPeakX(data, 1, zoom, 320); + expect((x + 320) / zoom).toBeCloseTo(12 + 512 / 48000, 10); + expect(x - recordingPeakX(data, 0, zoom, 320)).toBeCloseTo(512 / 48000 * zoom, 10); + } + }); +}); + +describe("live recording request lifecycle", () => { + it("coalesces zoom and scroll changes while one old request is outstanding", async () => { + const f = fixture(); + const pending = deferred(); + f.request.mockImplementationOnce(() => pending.promise); + const running = f.scheduler.poll(); + for (let step = 0; step < 12; step++) { + f.setView({ ...view(), pixelsPerSecond: 50 - step, scrollX: 550 * (50 - step) }); + f.scheduler.invalidate(); + await f.scheduler.poll(); + } + expect(f.request).toHaveBeenCalledTimes(1); + pending.resolve(peaks(1)); + await running; + await settle(); + expect(f.request).toHaveBeenCalledTimes(3); + expect(f.request.mock.calls.map(call => call[0])).toEqual(["a", "a", "b"]); + expect(f.publish).toHaveBeenCalledTimes(2); + const final = planRecordingPreviewWindow({ ...view(), pixelsPerSecond: 39, scrollX: 550 * 39 }, view().clips[0])!; + expect(f.cache.get("a")?.samplesPerPixel).toBe(final.samplesPerPixel); + }); + + it("Stop clears the preview and prevents an obsolete second-track request or publication", async () => { + const f = fixture(); + await f.scheduler.poll(); + const pending = deferred(); + f.setView({ ...view(), currentTime: 557 }); + f.request.mockImplementationOnce(() => pending.promise); + const running = f.scheduler.poll(); + const count = f.request.mock.calls.length; + f.setView(null); + f.scheduler.invalidate(true); + const publications = f.publish.mock.calls.length; + pending.resolve(peaks(100)); + await running; + await settle(); + expect(f.cache.size).toBe(0); + expect(f.request).toHaveBeenCalledTimes(count); + expect(f.publish).toHaveBeenCalledTimes(publications); + }); + + it("ignores an unmounted completion and can resume after a StrictMode remount", async () => { + const f = fixture(); + const pending = deferred(); + f.request.mockImplementationOnce(() => pending.promise); + const running = f.scheduler.poll(); + f.scheduler.setActive(false); + pending.reject(new Error("old request")); + await running; + expect(f.publish).not.toHaveBeenCalled(); + expect(f.onError).not.toHaveBeenCalled(); + f.scheduler.setActive(true); + await f.scheduler.poll(); + expect(f.cache.size).toBe(2); + }); + + it("appends returned prefixes without refetching already completed bins", async () => { + const f = fixture(); + f.request.mockImplementation(async (_track, _spp, count) => peaks(Math.min(100, count))); + await f.scheduler.poll(); + const first = f.request.mock.calls.slice(); + f.setView({ ...view(), currentTime: 557 }); // Time alone must not invalidate the cache. + await f.scheduler.poll(); + expect(f.request.mock.calls[2][3]).toBe(first[0][3] + 100 * first[0][1]); + expect(f.cache.get("a")?.peaks.length).toBe(200); + expect(f.cache.get("b")?.peaks.length).toBe(200); + }); + + it("bounds native work at minimum zoom for multi-hour takes and caps oversized responses", async () => { + const f = fixture(); + f.setView({ ...view(), sampleRate: 384000, pixelsPerSecond: 1, scrollX: 10000, currentTime: 14400, width: 3840 }); + f.request.mockImplementation(async (_track, _spp, count) => peaks(count + 20)); + await f.scheduler.poll(); + for (const [track, spp, count] of f.request.mock.calls) { + expect(count).toBeLessThanOrEqual(MAX_RECORDING_PREVIEW_PEAKS); + expect(count * (spp / RECORDING_PEAK_STRIDE + 1)).toBeLessThanOrEqual(MAX_RECORDING_PEAK_BASE_ENTRIES); + expect(f.cache.get(track)?.peaks.length).toBe(count); + } + }); + + it("suspends fetching during a gesture and retires removed or newly recorded tracks", async () => { + const f = fixture(); + f.suspend(true); + await f.scheduler.poll(); + expect(f.request).not.toHaveBeenCalled(); + f.suspend(false); + await f.scheduler.poll(); + f.setView({ ...view(), clips: [{ trackId: "a", startTime: 555 }] }); + f.scheduler.invalidate(); + await f.scheduler.poll(); + expect([...f.cache.keys()]).toEqual(["a"]); + expect(f.cache.get("a")?.recordingStartTime).toBe(555); + expect(f.cache.get("a")?.startSample).toBe(0); + }); +}); diff --git a/frontend/src/__tests__/reverbControlLayout.test.ts b/frontend/src/__tests__/reverbControlLayout.test.ts index fe39853..a2e0897 100644 --- a/frontend/src/__tests__/reverbControlLayout.test.ts +++ b/frontend/src/__tests__/reverbControlLayout.test.ts @@ -10,6 +10,46 @@ function schema(type: number, values: Record = {}): BuiltInPlugi const visibleIds = (layout: ReturnType) => [layout.type === 7 ? "ir.shape" : layout.primary.id, ...layout.shape.map(control => control.id), ...layout.pages.flatMap(page => page.controls.map(control => control.id))]; describe("approved Reverb native applicability", () => { + it("exposes the saved control behavior independently of Processing on every type", () => { + for (let type=0; type<41; ++type) { + const layout=reverbControlLayout(schema(type,{ reverbControlMode: 0, reverbReturnMode: 1 })); + const character=layout.pages.find(page=>page.id==="setup")!; + expect(character.controls[0].id).toBe("reverbReturnMode"); + expect(character.controls.find(control=>control.id==="reverbControlMode")?.description).toContain("older saved states load Legacy"); + expect(character.help).toContain("Wet/Dry"); + } + }); + it("shows pitch scheduling only for spectral Shimmer and broadband body only for Vowel", () => { + for (let type = 0; type < 41; ++type) { + const layout = reverbControlLayout(schema(type)); + expect(visibleIds(layout).includes("shimmerScheduling")).toBe(type === 5); + expect(visibleIds(layout).includes("ambBody")).toBe(type === 20); + } + expect(visibleIds(reverbControlLayout(schema(5, { shimmerVoiceEngine: 0 })))).not.toContain("shimmerScheduling"); + expect(reverbControlLayout(schema(5)).pages.find(page => page.id === "character")?.help).toContain("30 ms hop"); + expect(reverbControlLayout(schema(20)).pages.find(page => page.id === "character")?.help).toContain("feedback tank are unchanged"); + }); + it("offers shared Return level in Setup on every type, including legacy and convolution engines", () => { + for (let type = 0; type < 41; ++type) { + const layout = reverbControlLayout(schema(type, { reverbReturnMode: 1, spaceEngine: 0, plateEngineExpanded: 0, springEngine: 0 })); + const character = layout.pages.find(page => page.id === "setup")!; + expect(character.controls[0].id).toBe("reverbReturnMode"); + expect(character.controls[0].description).toContain("fixed wet-return trims"); + expect(character.controls[0].description).toContain("Legacy Room/Hall/Chamber/Plate and custom IRs retain their levels"); + expect(character.help).toContain("older states without this choice use Original"); + expect(visibleIds(layout).filter(id => id === "reverbReturnMode")).toHaveLength(1); + } + }); + it("offers Refined processing only where an active engine uses it", () => { + for (let type = 0; type < 41; ++type) { + const layout = reverbControlLayout(schema(type, { reverbCharacterMode: 0 })); + const applicable = type === 2 || type === 19 || (type >= 21 && type <= 35) || type >= 37; + expect(visibleIds(layout).includes("reverbCharacterMode")).toBe(applicable); + } + for (const plateEngineExpanded of [0, 2]) { + expect(visibleIds(reverbControlLayout(schema(2, { reverbCharacterMode: 0, plateEngineExpanded })))).not.toContain("reverbCharacterMode"); + } + }); it("uses the expanded native identity for all41 types, with timing and routing reachable", () => { for (let type = 0; type < 41; ++type) { const result = reverbControlLayout(schema(type)); diff --git a/frontend/src/__tests__/reverbParameterPresentation.test.ts b/frontend/src/__tests__/reverbParameterPresentation.test.ts new file mode 100644 index 0000000..0cb9fb2 --- /dev/null +++ b/frontend/src/__tests__/reverbParameterPresentation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import type { BuiltInParamDescriptor } from "../services/NativeBridge"; +import { reverbParameterPresentation } from "../utils/reverbParameterPresentation"; + +const parameter: BuiltInParamDescriptor = { id: "roomSize", label: "Size", type: "continuous", min: 0, max: 1, value: .5, defaultValue: .5 }; +describe("reverb presentation preserves native automation", () => { + it("displays shaped duration in milliseconds and converts entered values back", () => { + const view = reverbParameterPresentation(parameter, 36); + expect(view.parameter).toMatchObject({ id: "roomSize", unit: "ms", min: 25, max: 2000, value: 1012.5, defaultValue: 1012.5 }); + expect(view.toNative(25)).toBe(0); + expect(view.toNative(2000)).toBe(1); + expect(view.toNative(500)).toBeCloseTo(475 / 1975); + expect(view.toNative(view.parameter.defaultValue)).toBe(.5); + expect(parameter.value).toBe(.5); + }); + it("leaves other room controls and their native IDs untouched", () => { + for (let type = 0; type < 41; ++type) if (type !== 36) { + const view = reverbParameterPresentation(parameter, type); + expect(view.parameter).toBe(parameter); + expect(view.toNative(.42)).toBe(.42); + } + }); + it("resets Shimmer to its bank default without changing the stored current value", () => { + const amount = { ...parameter, id: "shimmerAmount", value: .8, defaultValue: 0 }; + expect(reverbParameterPresentation(amount, 5).parameter).toMatchObject({ value: .8, defaultValue: .5 }); + expect(reverbParameterPresentation(amount, 0).parameter.defaultValue).toBe(0); + }); +}); diff --git a/frontend/src/__tests__/shortcutRegistry.test.ts b/frontend/src/__tests__/shortcutRegistry.test.ts index 0fed0eb..c4c81fc 100644 --- a/frontend/src/__tests__/shortcutRegistry.test.ts +++ b/frontend/src/__tests__/shortcutRegistry.test.ts @@ -23,8 +23,8 @@ describe("shortcut registry", () => { expect(getActionShortcut("file.closeProject")).toBe("Ctrl+F4"); expect(getActionShortcut("file.quit")).toBe("Ctrl+Q"); expect(getActionShortcut("file.render")).toBe("Ctrl+Alt+R"); - expect(getActionShortcut("view.zoomIn")).toBe("Ctrl++"); - expect(getActionShortcut("view.zoomOut")).toBe("Ctrl+-"); + expect(getActionShortcut("view.zoomIn")).toBe("+"); + expect(getActionShortcut("view.zoomOut")).toBe("-"); expect(getActionShortcut("view.zoomToFit")).toBe("Ctrl+0"); }); @@ -59,7 +59,7 @@ describe("shortcut registry", () => { }); it("uses the selected profile between custom overrides and registry defaults", () => { - expect(getActionShortcut("transport.rewind")).toBe("Home"); + expect(getActionShortcut("transport.rewind")).toBe("1"); useDAWStore.setState({ keyboardShortcutProfileId: "reaper", diff --git a/frontend/src/__tests__/singleKeyTransportShortcuts.test.ts b/frontend/src/__tests__/singleKeyTransportShortcuts.test.ts new file mode 100644 index 0000000..b0fad19 --- /dev/null +++ b/frontend/src/__tests__/singleKeyTransportShortcuts.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; +import { + dispatchGlobalShortcut, + getEffectiveActionShortcuts, + matchesActionShortcut, + resolveRegistryShortcutAction, +} from "../utils/globalShortcutDispatcher"; +import { activateShortcutContext, resetShortcutContextForTests } from "../utils/shortcutContext"; + +const original = useDAWStore.getState(); +const platforms = ["windows", "macos"] as const; +let testNow = Date.now(); + +beforeEach(() => { + resetShortcutContextForTests(); + const track = createDefaultTrack("record-track", "Record", "#123456", "audio", []); + track.armed = true; + useDAWStore.setState({ + ...original, + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + keyboardShortcutProfileId: "openstudio", + customShortcuts: {}, + stepInputEnabled: false, + }); + vi.spyOn(Date, "now").mockImplementation(() => testNow += 200); +}); + +afterEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState(original); + vi.restoreAllMocks(); +}); + +describe("single-key transport and zoom", () => { + it.each(platforms)("records from every editor scope on %s", platform => { + const toggleRecord = vi.fn(); + useDAWStore.setState({ toggleRecord }); + for (const kind of ["timeline", "timeline_ruler", "track_control_panel", "mixer", "piano_roll", "pitch_editor"] as const) { + activateShortcutContext(kind === "piano_roll" ? { kind, sessionId: "record-test" } : { kind }); + for (const recordMode of ["normal", "overdub", "replace"] as const) { + useDAWStore.setState({ recordMode }); + expect(dispatchGlobalShortcut({ key: "r", code: "KeyR", source: "test" }, platform)).toBe(true); + } + } + expect(toggleRecord).toHaveBeenCalledTimes(18); + expect(matchesActionShortcut({ key: "r", code: "KeyR", ...(platform === "macos" ? { metaKey: true } : { ctrlKey: true }) }, "transport.record", platform)).toBe(true); + expect(matchesActionShortcut({ key: "*", code: "NumpadMultiply", location: 3 }, "transport.record", platform)).toBe(true); + expect(matchesActionShortcut({ key: "*", code: "Digit8", shiftKey: true }, "transport.record", platform)).toBe(false); + }); + + it("does not record while typing or repeatedly toggle a held key", () => { + const toggleRecord = vi.fn(); + useDAWStore.setState({ toggleRecord }); + expect(dispatchGlobalShortcut({ key: "r", code: "KeyR", source: "test", targetIsEditable: true }, "windows")).toBe(false); + expect(dispatchGlobalShortcut({ key: "r", code: "KeyR", source: "test", repeat: true }, "windows")).toBe(true); + expect(toggleRecord).not.toHaveBeenCalled(); + }); + + it("forwards detached editor Record to the main transport", () => { + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + activateShortcutContext({ kind: "piano_roll", sessionId: "detached-record" }); + expect(dispatchGlobalShortcut({ key: "r", code: "KeyR", source: "test" }, "windows", { role: "midi" })).toBe(true); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ command: "action.execute", actionId: "transport.record" })); + }); + + it.each(platforms)("keeps track arm and MIDI range reachable on %s", platform => { + const event = { key: "R", code: "KeyR", shiftKey: true, ...(platform === "macos" ? { metaKey: true } : { ctrlKey: true }) }; + activateShortcutContext({ kind: "track_control_panel" }); + expect(resolveRegistryShortcutAction(event, platform, { canHandleAction: () => true })?.action.id).toBe("track.toggleSelectedArm"); + activateShortcutContext({ kind: "piano_roll", sessionId: "range-test" }); + expect(resolveRegistryShortcutAction(event, platform, { canHandleAction: () => true })?.action.id).toBe("midi.tool.range"); + }); + + it("seeks to absolute zero and reveals the project beginning with 1 or Home", async () => { + const setPosition = vi.spyOn(nativeBridge, "setTransportPosition").mockResolvedValue(true); + const seekTo = vi.spyOn(useDAWStore.getState(), "seekTo"); + const selection = { start: 12, end: 20 }; + useDAWStore.setState({ + scrollX: 800, + scrollY: 140, + timeSelection: selection, + transport: { ...original.transport, currentTime: 14, isPlaying: false, isRecording: false, loopEnabled: true, loopStart: 12, loopEnd: 20 }, + }); + activateShortcutContext({ kind: "pitch_editor" }); + expect(resolveRegistryShortcutAction({ key: "1", code: "Digit1" }, "windows", { canHandleAction: () => true })?.action.id).toBe("transport.rewind"); + expect(matchesActionShortcut({ key: "Home", code: "Home" }, "transport.rewind", "windows")).toBe(true); + await getRegisteredAction("transport.rewind")?.execute(); + expect(seekTo).toHaveBeenCalledWith(0); + expect(setPosition).toHaveBeenCalledWith(0); + expect(useDAWStore.getState()).toMatchObject({ scrollX: 0, scrollY: 140, timeSelection: selection, transport: { currentTime: 0, loopStart: 12, loopEnd: 20 } }); + expect(matchesActionShortcut({ key: "v", code: "KeyV" }, "pitch.tool.select", "windows")).toBe(true); + }); + + it("accepts main-keyboard and numpad zoom keys over the full timeline range", () => { + for (const event of [ + { key: "+", code: "Equal", shiftKey: true }, + { key: "=", code: "Equal" }, + { key: "+", code: "NumpadAdd", location: 3 }, + ]) expect(matchesActionShortcut(event, "view.zoomIn", "windows")).toBe(true); + for (const event of [ + { key: "-", code: "Minus" }, + { key: "-", code: "NumpadSubtract", location: 3 }, + ]) expect(matchesActionShortcut(event, "view.zoomOut", "windows")).toBe(true); + + useDAWStore.setState({ pixelsPerSecond: 900 }); + getRegisteredAction("view.zoomIn")?.execute(); + expect(useDAWStore.getState().pixelsPerSecond).toBe(1000); + useDAWStore.setState({ pixelsPerSecond: 1.1 }); + getRegisteredAction("view.zoomOut")?.execute(); + expect(useDAWStore.getState().pixelsPerSecond).toBe(1); + }); + + it("preserves source profiles and explicit custom overrides", () => { + useDAWStore.setState({ keyboardShortcutProfileId: "ardour" }); + expect(matchesActionShortcut({ key: "+", code: "NumpadAdd" }, "view.zoomIn", "windows")).toBe(false); + expect(matchesActionShortcut({ key: "+", code: "NumpadAdd" }, "edit.nudgeRight", "windows")).toBe(true); + useDAWStore.setState({ keyboardShortcutProfileId: "cubase" }); + activateShortcutContext({ kind: "timeline" }); + expect(resolveRegistryShortcutAction({ key: "1", code: "Digit1" }, "windows", { canHandleAction: () => true })?.action.id).toBe("tools.selectTool"); + useDAWStore.setState({ keyboardShortcutProfileId: "openstudio", customShortcuts: { "transport.record": "F12", "view.zoomIn": "" } }); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "transport.record", "windows")).toBe(false); + expect(matchesActionShortcut({ key: "F12", code: "F12" }, "transport.record", "windows")).toBe(true); + expect(getEffectiveActionShortcuts(getRegisteredAction("view.zoomIn")!, "windows")).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/suiteParameterGesture.test.ts b/frontend/src/__tests__/suiteParameterGesture.test.ts index 496f856..f51fd25 100644 --- a/frontend/src/__tests__/suiteParameterGesture.test.ts +++ b/frontend/src/__tests__/suiteParameterGesture.test.ts @@ -2,15 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { BuiltInParamDescriptor } from "../services/NativeBridge"; import { useDAWStore } from "../store/useDAWStore"; import { createSuiteParameterWheel, parseSuiteParameterDraft } from "../utils/suiteParameterGesture"; -import { BuiltInPluginParamHistory } from "../utils/builtInPluginParamHistory"; +import { BuiltInPluginParamHistory, type BuiltInPluginHistoryReplay } from "../utils/builtInPluginParamHistory"; const originalProfile = useDAWStore.getState().mouseBehaviorProfileId; const descriptor = (value = .5): BuiltInParamDescriptor => ({ id: "mix", label: "Mix", type: "continuous", min: 0, max: 1, value, defaultValue: .5 }); afterEach(() => { useDAWStore.setState({ mouseBehaviorProfileId: originalProfile }); vi.useRealTimers(); }); -function control(initial = .5) { - let parameter = descriptor(initial); - const history = new BuiltInPluginParamHistory("chorus-instance"); +function control(initial = .5, overrides: Partial = {}) { + let parameter = { ...descriptor(initial), ...overrides }; + const history = new BuiltInPluginParamHistory("suite-instance"); const commits: Promise[] = []; const begin = vi.fn(() => { history.begin(parameter.id, parameter.label, parameter.value); }); const commit = vi.fn(() => { commits.push(history.commit(async () => true)); }); @@ -31,7 +31,7 @@ describe("suite knob gestures", () => { await vi.advanceTimersByTimeAsync(180); await Promise.all(knob.commits); expect(knob.commit).toHaveBeenCalledTimes(1); expect(knob.history.getUndoEntries()).toHaveLength(1); - const replay = vi.fn(async () => true); + const replay = vi.fn(async () => true); await expect(knob.history.undo(async () => true, replay)).resolves.toBe("applied"); expect(replay.mock.calls[0]).toEqual([expect.objectContaining({ changes: [{ paramId: "mix", label: "Mix", before: .5, after: expect.closeTo(.5008, 10) }] }), "before"]); await expect(knob.history.redo(async () => true, replay)).resolves.toBe("applied"); @@ -69,4 +69,25 @@ describe("suite knob gestures", () => { expect(parseSuiteParameterDraft("NaN", 100, descriptor())).toBeNull(); expect(parseSuiteParameterDraft("Infinity", 100, descriptor())).toBeNull(); }); + + it("keeps Reverb output edits in decibels and a wheel burst in one reversible gesture", async () => { + vi.useFakeTimers(); useDAWStore.setState({ mouseBehaviorProfileId: "cubase" }); + const output = { id: "outputTrimDb", label: "Output", min: -60, max: 12, defaultValue: 0, unit: "dB" }; + const knob = control(-9, output); + const parameter = { ...descriptor(-9), ...output }; + expect(parseSuiteParameterDraft("-9.125", 1, parameter)).toBe(-9.125); + expect(parseSuiteParameterDraft("-75", 1, parameter)).toBe(-60); + expect(parseSuiteParameterDraft("18", 1, parameter)).toBe(12); + knob.wheel.wheel({ deltaY: -100 }); knob.wheel.wheel({ deltaY: -100 }); + await vi.advanceTimersByTimeAsync(180); await Promise.all(knob.commits); + expect(knob.begin).toHaveBeenCalledTimes(1); expect(knob.commit).toHaveBeenCalledTimes(1); + expect(knob.history.getUndoEntries()).toEqual([{ instanceId: "suite-instance", changes: [{ + paramId: "outputTrimDb", label: "Output", before: -9, after: knob.value(), + }] }]); + const replay = vi.fn(async () => true); + await expect(knob.history.undo(async () => true, replay)).resolves.toBe("applied"); + await expect(knob.history.redo(async () => true, replay)).resolves.toBe("applied"); + expect(replay.mock.calls.map(call => call[1])).toEqual(["before", "after"]); + knob.wheel.dispose(); + }); }); diff --git a/frontend/src/__tests__/timelineClipCopyPlan.test.ts b/frontend/src/__tests__/timelineClipCopyPlan.test.ts new file mode 100644 index 0000000..deb8626 --- /dev/null +++ b/frontend/src/__tests__/timelineClipCopyPlan.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { planTimelineClipCopy, type TimelineCopySource } from "../utils/timelineClipCopyPlan"; +import type { Track } from "../store/useDAWStore"; + +const track = (id: string, type: Track["type"] = "audio", extra: Partial = {}) => ({ + id, type, frozen: extra.frozen ?? false, isFolder: extra.isFolder, +}); +const source = (clipId: string, sourceTrackId: string, isMidi = false): TimelineCopySource => ({ + clipId, sourceTrackId, isMidi, originalStartTime: 2, duration: 1, +}); + +describe("timeline copy destinations", () => { + it("uses source IDs when collapsed children make full and visible row indices differ", () => { + const plan = planTimelineClipCopy({ + tracks: [track("folder", "audio", { isFolder: true }), track("hidden"), track("a"), track("b")], + visibleTrackIds: ["folder", "a", "b"], sources: [source("ca", "a"), source("cb", "b")], + anchorTrackId: "a", targetRowIndex: 1, + }); + expect(plan?.placements.map(row => [row.clipId, row.targetRowIndex, row.targetTrackId])) + .toEqual([["ca", 1, "a"], ["cb", 2, "b"]]); + expect(plan?.newTracks).toEqual([]); + }); + + it("moves the entire mixed group to generated rows when one requested destination is incompatible", () => { + const plan = planTimelineClipCopy({ + tracks: [track("a"), track("m", "midi"), track("m2", "midi"), track("b")], + visibleTrackIds: ["a", "m", "m2", "b"], sources: [source("ca", "a"), source("cm", "m", true)], + anchorTrackId: "a", targetRowIndex: 1, + }); + expect(plan?.placements.map(row => [row.clipId, row.targetRowIndex, row.targetTrackId])) + .toEqual([["ca", 4, undefined], ["cm", 5, undefined]]); + expect(plan?.newTracks).toEqual([{ rowIndex: 4, type: "audio" }, { rowIndex: 5, type: "midi" }]); + }); + + it.each([{ frozen: true }, { isFolder: true }])("does not copy onto an unavailable target %j", extra => { + const plan = planTimelineClipCopy({ + tracks: [track("a"), track("blocked", "audio", extra)], visibleTrackIds: ["a", "blocked"], + sources: [source("ca", "a")], anchorTrackId: "a", targetRowIndex: 1, + }); + expect(plan?.placements[0].targetRowIndex).toBe(2); + expect(plan?.newTracks).toHaveLength(1); + }); + + it("retains row gaps and includes selected hidden sources instead of silently dropping them", () => { + const plan = planTimelineClipCopy({ + tracks: [track("folder", "audio", { isFolder: true }), track("hidden"), track("gap"), track("a")], + visibleTrackIds: ["folder", "a"], sources: [source("hiddenClip", "hidden"), source("ca", "a")], + anchorTrackId: "a", targetRowIndex: 1, + }); + expect(plan?.placements.map(row => [row.clipId, row.targetRowIndex])) + .toEqual([["hiddenClip", 2], ["ca", 4]]); + expect(plan?.newTracks).toHaveLength(3); + }); + + it("keeps a group above the pointer in bounds when dragging its last row upward", () => { + const plan = planTimelineClipCopy({ + tracks: [track("a"), track("gap"), track("b")], visibleTrackIds: ["a", "gap", "b"], + sources: [source("ca", "a"), source("cb", "b")], anchorTrackId: "b", targetRowIndex: 0, + }); + expect(plan?.targetAnchorRowIndex).toBe(2); + expect(plan?.placements.map(row => row.targetTrackId)).toEqual(["a", "b"]); + }); + + it("rejects a gesture whose source track disappeared", () => { + expect(planTimelineClipCopy({ tracks: [track("a")], visibleTrackIds: ["a"], + sources: [source("missing", "removed")], anchorTrackId: "a", targetRowIndex: 0 })).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/timelineClipGestureLock.test.ts b/frontend/src/__tests__/timelineClipGestureLock.test.ts index 3c574c0..984bb13 100644 --- a/frontend/src/__tests__/timelineClipGestureLock.test.ts +++ b/frontend/src/__tests__/timelineClipGestureLock.test.ts @@ -96,7 +96,9 @@ describe("timeline pointer gesture lock authority", () => { expect(timelineSource).toContain("cancelClipVolumeEdit(volumeGesture.clipId)"); expect(timelineSource).toContain("restoreTimelineGestureUndo();"); expect(timelineSource).toContain("runTimelineClipGestureMutation("); - expect(timelineSource.match(/draggable=\{!clipEditLocked\}/g)).toHaveLength(6); + // Preview groups add a second restriction; all six real clip drag controls + // still retain the current clip-edit lock gate. + expect(timelineSource.match(/draggable=\{(?:!isCopyPreview && )?!clipEditLocked\}/g)).toHaveLength(6); expect(timelineSource).not.toContain("draggable={!clip.locked}"); }); }); diff --git a/frontend/src/__tests__/timelineGroupCopy.test.ts b/frontend/src/__tests__/timelineGroupCopy.test.ts new file mode 100644 index 0000000..eb5d6d7 --- /dev/null +++ b/frontend/src/__tests__/timelineGroupCopy.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { commandManager } from "../store/commands"; +import { nativeBridge } from "../services/NativeBridge"; +import { createDefaultTrack, type AudioClip, type MIDIClip, useDAWStore } from "../store/useDAWStore"; + +const initialState = useDAWStore.getState(); +function audio(id: string, startTime: number, duration: number): AudioClip { + return { id, filePath: `${id}.wav`, name: id, startTime, duration, offset: 0, color: "#334455", volumeDB: 0, fadeIn: 0, fadeOut: 0 }; +} +function midi(id: string, startTime: number): MIDIClip { + return { id, name: id, startTime, duration: 2, sourceLength: 2, color: "#445566", events: [{ timestamp: 0.5, type: "noteOn", note: 64, velocity: 92 }], ccEvents: [{ cc: 1, time: 0.4, value: 60 }] }; +} +beforeEach(() => { + commandManager.clear(); + const source = createDefaultTrack("a", "Source", "#334455", "audio", []); + source.clips = [audio("long", 1, 5), audio("short", 8, 1)]; + const notes = createDefaultTrack("m", "MIDI", "#445566", "midi", []); + notes.midiClips = [midi("notes", 4)]; + useDAWStore.setState({ tracks: [source, notes], selectedClipId: "short", selectedClipIds: ["long", "notes", "short"], 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(() => { commandManager.clear(); useDAWStore.setState(initialState); vi.restoreAllMocks(); }); + +describe("group duplication and copy placement", () => { + it("duplicates an unequal-length, gapped, mixed selection immediately after its whole span", () => { + const ids = useDAWStore.getState().duplicateSelectedClips(); + expect(ids).toHaveLength(3); + const state = useDAWStore.getState(); + expect(state.tracks[0].clips.map((clip) => clip.startTime)).toEqual([1, 8, 9, 16]); + expect(state.tracks[1].midiClips.map((clip) => clip.startTime)).toEqual([4, 12]); + expect(commandManager.getUndoStack()).toHaveLength(1); + state.undo(); + expect(useDAWStore.getState().selectedClipIds).toEqual(["long", "notes", "short"]); + expect(useDAWStore.getState().tracks[0].clips).toHaveLength(2); + state.redo(); + expect(useDAWStore.getState().selectedClipIds).toEqual(ids); + }); + + it("copies all clips at one time delta and keeps originals/nested MIDI content independent", () => { + const ids = useDAWStore.getState().duplicateClipsToPositions([ + { clipId: "long", targetTrackId: "a", targetStartTime: 11 }, + { clipId: "notes", targetTrackId: "m", targetStartTime: 14 }, + { clipId: "short", targetTrackId: "a", targetStartTime: 18 }, + ]); + const state = useDAWStore.getState(); + expect(ids).toHaveLength(3); + expect(state.tracks[0].clips.map((clip) => clip.startTime)).toEqual([1, 8, 11, 18]); + expect(state.tracks[1].midiClips.map((clip) => clip.startTime)).toEqual([4, 14]); + expect(state.tracks[1].midiClips[1].events).not.toBe(state.tracks[1].midiClips[0].events); + expect(state.tracks[1].midiClips[1].ccEvents).not.toBe(state.tracks[1].midiClips[0].ccEvents); + expect(commandManager.getUndoStack()).toHaveLength(1); + state.undo(); + expect(useDAWStore.getState().tracks[1].midiClips).toHaveLength(1); + state.redo(); + expect(useDAWStore.getState().selectedClipIds).toEqual(ids); + }); + + it("defers all backend writes while copying audio, MIDI and automation for an owning gesture", () => { + const syncAutomation = vi.spyOn(nativeBridge, "setAutomationPoints").mockResolvedValue(true); + const syncMode = vi.spyOn(nativeBridge, "setAutomationMode").mockResolvedValue(true); + const clearAutomation = vi.spyOn(nativeBridge, "clearAutomation").mockResolvedValue(true); + const before = useDAWStore.getState(); + useDAWStore.setState({ moveEnvelopesWithItems: true, tracks: before.tracks.map(track => track.id !== "a" ? track : { + ...track, + automationLanes: [{ id: "volume", param: "volume", points: [{ id: "source-point", time: 2, value: 0.6 }], visible: true, mode: "read", armed: false, readEnabled: true }], + }) }); + const ids = useDAWStore.getState().duplicateClipsToPositions([ + { clipId: "long", targetTrackId: "a", targetStartTime: 11 }, + { clipId: "notes", targetTrackId: "m", targetStartTime: 14 }, + ], { recordUndo: false, syncBackend: false }); + const after = useDAWStore.getState(); + expect(ids).toHaveLength(2); + expect(after.tracks[0].clips).toHaveLength(3); + expect(after.tracks[1].midiClips).toHaveLength(2); + expect(after.tracks[0].automationLanes[0].points).toEqual([ + { id: "source-point", time: 2, value: 0.6 }, + { id: expect.any(String), time: 12, value: 0.6 }, + ]); + expect(after.selectedClipIds).toEqual(ids); + expect(after.syncClipsWithBackend).not.toHaveBeenCalled(); + expect(after.syncMIDITrackToBackend).not.toHaveBeenCalled(); + expect(syncAutomation).not.toHaveBeenCalled(); + expect(syncMode).not.toHaveBeenCalled(); + expect(clearAutomation).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("keeps nested audio takes and Undo snapshots independent from live copied content", () => { + const before = useDAWStore.getState(); + useDAWStore.setState({ tracks: before.tracks.map(track => ({ ...track, clips: track.clips.map(clip => clip.id !== "long" ? clip : { + ...clip, takes: [{ ...audio("nested-take", 1, 2), gainEnvelope: [{ time: 0.5, gain: 0.7 }] }], + }) })) }); + const [id] = useDAWStore.getState().duplicateClipsToPositions([{ clipId: "long", targetTrackId: "a", targetStartTime: 11 }]); + const copied = useDAWStore.getState().tracks[0].clips.find(clip => clip.id === id)!; + expect(copied.takes?.[0].id).not.toBe("nested-take"); + copied.takes![0].gainEnvelope![0].gain = 0.2; + expect(useDAWStore.getState().tracks[0].clips[0].takes?.[0].gainEnvelope?.[0].gain).toBe(0.7); + useDAWStore.getState().undo(); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips.find(clip => clip.id === id)?.takes?.[0].gainEnvelope?.[0].gain).toBe(0.7); + }); + + it.each(["incompatible", "missing", "locked"])("rejects the entire copy group when one source or target is %s", (reason) => { + if (reason === "locked") useDAWStore.setState({ globalLocked: true }); + const before = useDAWStore.getState().tracks; + expect(useDAWStore.getState().duplicateClipsToPositions([ + { clipId: "long", targetTrackId: "a", targetStartTime: 11 }, + { clipId: "notes", targetTrackId: reason === "incompatible" ? "a" : reason === "missing" ? "missing" : "m", targetStartTime: 14 }, + ])).toEqual([]); + expect(useDAWStore.getState().tracks).toBe(before); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/timelineRulerDrag.test.ts b/frontend/src/__tests__/timelineRulerDrag.test.ts new file mode 100644 index 0000000..e91cb3b --- /dev/null +++ b/frontend/src/__tests__/timelineRulerDrag.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { getRulerDragZoom, resolveRulerDragAxis } from "../utils/timelineRulerDrag"; + +describe("timeline ruler direction and time zoom", () => { + it("treats a click as pending and locks the first dominant direction", () => { + expect(resolveRulerDragAxis(null, 3, 2)).toBeNull(); + expect(resolveRulerDragAxis(null, 7, -20)).toBe("y"); + expect(resolveRulerDragAxis("y", 70, -2)).toBe("y"); + expect(resolveRulerDragAxis(null, 20, 7)).toBe("x"); + expect(resolveRulerDragAxis("x", 2, 70)).toBe("x"); + }); + + it("zooms out upward, in downward, with the original time under the cursor", () => { + const up = getRulerDragZoom(100, -30, 10, 200); + const down = getRulerDragZoom(100, 30, 10, 200); + expect(up.pixelsPerSecond).toBeLessThan(100); + expect(down.pixelsPerSecond).toBeGreaterThan(100); + expect((up.scrollX + 200) / up.pixelsPerSecond).toBeCloseTo(10); + expect((down.scrollX + 200) / down.pixelsPerSecond).toBeCloseTo(10); + }); + + it("clamps time zoom and scroll at the supported boundaries", () => { + expect(getRulerDragZoom(100, 10000, 0, 200)).toEqual({ pixelsPerSecond: 1000, scrollX: 0 }); + expect(getRulerDragZoom(100, -10000, 0, 200)).toEqual({ pixelsPerSecond: 1, scrollX: 0 }); + }); +}); diff --git a/frontend/src/__tests__/tone3000InfiniteAppend.test.ts b/frontend/src/__tests__/tone3000InfiniteAppend.test.ts index 2a3635c..20a5cb9 100644 --- a/frontend/src/__tests__/tone3000InfiniteAppend.test.ts +++ b/frontend/src/__tests__/tone3000InfiniteAppend.test.ts @@ -1,4 +1,3 @@ -// @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 { diff --git a/frontend/src/__tests__/trackCopyPlanning.test.ts b/frontend/src/__tests__/trackCopyPlanning.test.ts new file mode 100644 index 0000000..ad27317 --- /dev/null +++ b/frontend/src/__tests__/trackCopyPlanning.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { insertTrackCopies, trackDragSourceIds, type TrackCopySource } from "../utils/trackCopyPlanning"; + +describe("track drag copying", () => { + const tracks = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; + const copies = [ + { sourceTrack: tracks[0], duplicate: { id: "a-copy" } }, + { sourceTrack: tracks[2], duplicate: { id: "c-copy" } }, + ]; + it("copies the selected group in project order when grabbing a selected row", () => { + expect(trackDragSourceIds(tracks, ["c", "a", "missing"], "a")).toEqual(["a", "c"]); + expect(trackDragSourceIds(tracks, ["c", "a"], "b")).toEqual(["b"]); + }); + it("inserts the whole group at the drop row without moving the originals", () => { + expect(insertTrackCopies(tracks, copies, "d").map((track) => track.id)).toEqual([ + "a", "b", "c", "a-copy", "c-copy", "d", + ]); + expect(tracks.map((track) => track.id)).toEqual(["a", "b", "c", "d"]); + }); + it("keeps normal duplicate placement when dropping back on the source", () => { + expect(insertTrackCopies(tracks, copies).map((track) => track.id)).toEqual([ + "a", "a-copy", "b", "c", "c-copy", "d", + ]); + }); + + const folderTracks: TrackCopySource[] = [ + { id: "folder", isFolder: true }, + { id: "child", parentFolderId: "folder" }, + { id: "nested", parentFolderId: "folder", isFolder: true }, + { id: "leaf", parentFolderId: "nested" }, + { id: "outside" }, + ]; + const folderCopies = folderTracks.slice(0, 4).map((sourceTrack) => ({ + sourceTrack, + duplicate: { ...sourceTrack, id: `${sourceTrack.id}-copy`, parentFolderId: sourceTrack.parentFolderId ? `${sourceTrack.parentFolderId}-copy` : undefined }, + })); + + it("includes a selected folder's complete subtree and de-duplicates selected children", () => { + expect(trackDragSourceIds(folderTracks, ["folder", "leaf"], "folder")).toEqual(["folder", "child", "nested", "leaf"]); + expect(trackDragSourceIds(folderTracks, ["folder"], "outside")).toEqual(["outside"]); + }); + + it("inserts a duplicated folder after its original subtree without interleaving children", () => { + const result = insertTrackCopies(folderTracks, folderCopies); + expect(result.map((track) => track.id)).toEqual(["folder", "child", "nested", "leaf", "folder-copy", "child-copy", "nested-copy", "leaf-copy", "outside"]); + expect(result.find((track) => track.id === "leaf-copy")?.parentFolderId).toBe("nested-copy"); + }); + + it("places copied child roots at the destination hierarchy level without changing their originals", () => { + const sourceTrack = folderTracks[1]; + const duplicate = { ...sourceTrack, id: "child-copy" }; + const result = insertTrackCopies(folderTracks, [{ sourceTrack, duplicate }], "folder"); + expect(result[0]).toEqual({ id: "child-copy", parentFolderId: undefined }); + expect(duplicate.parentFolderId).toBe("folder"); + expect(folderTracks[1].parentFolderId).toBe("folder"); + }); + + it("reparents copied folder roots at a nested drop while retaining copied internal links", () => { + const result = insertTrackCopies(folderTracks, folderCopies, "leaf"); + expect(result.find((track) => track.id === "folder-copy")?.parentFolderId).toBe("nested"); + expect(result.find((track) => track.id === "child-copy")?.parentFolderId).toBe("folder-copy"); + expect(result.find((track) => track.id === "leaf-copy")?.parentFolderId).toBe("nested-copy"); + }); +}); diff --git a/frontend/src/assets/nam/design/bodies/octaver-fuzz-faceplate.svg b/frontend/src/assets/nam/design/bodies/octaver-fuzz-faceplate.svg new file mode 100644 index 0000000..cd94e6b --- /dev/null +++ b/frontend/src/assets/nam/design/bodies/octaver-fuzz-faceplate.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + CLEAN + VOICE + + + DOWN + UP + DIRECT + FUZZ + TONE + LEVEL + + OCTAVER FUZZ + + OCTAVE + FUZZ + + + diff --git a/frontend/src/components/EssentialControlsCard.tsx b/frontend/src/components/EssentialControlsCard.tsx index 1815b2a..55b8ccb 100644 --- a/frontend/src/components/EssentialControlsCard.tsx +++ b/frontend/src/components/EssentialControlsCard.tsx @@ -41,7 +41,7 @@ export function EssentialControlsCard() { const shortcuts = useMemo(() => ({ help: getEffectiveShortcutLabel("help.contextualHelp", "F1"), play: getEffectiveShortcutLabel("transport.play", "Space"), - record: getEffectiveShortcutLabel("transport.record", "Ctrl+R"), + record: getEffectiveShortcutLabel("transport.record", "R"), addTrack: getEffectiveShortcutLabel("insert.audioTrack", "Ctrl+T"), mixer: getEffectiveShortcutLabel("view.toggleMixer", "Ctrl+M"), }), [customShortcuts, keyboardShortcutProfileId]); diff --git a/frontend/src/components/GettingStartedGuide.tsx b/frontend/src/components/GettingStartedGuide.tsx index 033ae74..99cf84a 100644 --- a/frontend/src/components/GettingStartedGuide.tsx +++ b/frontend/src/components/GettingStartedGuide.tsx @@ -75,7 +75,7 @@ function buildGuideSteps(): GuideStep[] { const audioTrackShortcut = shortcut("insert.audioTrack", "Ctrl+T"); const midiTrackShortcut = shortcut("insert.midiTrack", "Ctrl+Shift+T"); const instrumentTrackShortcut = shortcut("insert.quickAddInstrument", "Ctrl+Shift+I"); - const recordShortcut = shortcut("transport.record", "Ctrl+R"); + const recordShortcut = shortcut("transport.record", "R"); const preferencesShortcut = shortcut("options.preferences", "Ctrl+,"); const importShortcut = shortcut("insert.mediaFile", "Insert"); const saveShortcut = shortcut("file.save", "Ctrl+S"); diff --git a/frontend/src/components/InputGestureReference.tsx b/frontend/src/components/InputGestureReference.tsx index f84b48f..6d98b72 100644 --- a/frontend/src/components/InputGestureReference.tsx +++ b/frontend/src/components/InputGestureReference.tsx @@ -4,6 +4,7 @@ import { useDAWStore } from "../store/useDAWStore"; import { getMouseBehaviorProfile, toMouseBehaviorPlatform } from "../utils/mouseBehaviorProfiles"; import { getShortcutPlatform } from "../utils/platform"; import { resolveWheelGesture, type WheelSurface, type WheelSubtarget } from "../utils/wheelGestureResolver"; +import { resolveMouseModifierAction, type MouseModifierOverrideMap } from "../utils/mouseModifierResolver"; import { NativeSelect } from "./ui"; const surfaces: { value: string; label: string; surface: WheelSurface; subtarget: WheelSubtarget }[] = [ @@ -19,7 +20,7 @@ const surfaces: { value: string; label: string; surface: WheelSurface; subtarget export function InputGestureReference() { const [surfaceId, setSurfaceId] = useState("timeline"); - const { profileId } = useDAWStore(useShallow((s) => ({ profileId: s.mouseBehaviorProfileId }))); + const { profileId, mouseModifiers } = useDAWStore(useShallow((s) => ({ profileId: s.mouseBehaviorProfileId, mouseModifiers: s.mouseModifiers }))); const platform = getShortcutPlatform(); const profile = getMouseBehaviorProfile(profileId, platform); const context = surfaces.find((s) => s.value === surfaceId)!; @@ -48,6 +49,16 @@ export function InputGestureReference() { : "No assigned action"; return { label: [...keys, "Wheel"].join(" + "), action }; }); + if (surfaceId === "ruler" && resolveMouseModifierAction({}, "ruler_click", { + platform: toMouseBehaviorPlatform(platform), profile: profile.modifiers, + overrides: mouseModifiers as MouseModifierOverrideMap, + }) === "seek") { + rows.unshift( + { label: "Click", action: "Seek playhead" }, + { label: "Horizontal drag", action: "Select time" }, + { label: "Vertical drag", action: "Zoom time: up out, down in" }, + ); + } return

Current mappings for {profile.name}. Behavior depends on where the pointer is.

diff --git a/frontend/src/components/MainToolbar.tsx b/frontend/src/components/MainToolbar.tsx index 984cbf7..a59e518 100644 --- a/frontend/src/components/MainToolbar.tsx +++ b/frontend/src/components/MainToolbar.tsx @@ -86,7 +86,7 @@ export function MainToolbar({ const shortcuts = useMemo(() => ({ mixer: getEffectiveShortcutLabel("view.toggleMixer", "Ctrl+M"), loop: getEffectiveShortcutLabel("transport.loop", "L"), - record: getEffectiveShortcutLabel("transport.record", "Ctrl+R"), + record: getEffectiveShortcutLabel("transport.record", "R"), undo: getEffectiveShortcutLabel("edit.undo", "Ctrl+Z"), redo: getEffectiveShortcutLabel("edit.redo", "Ctrl+Shift+Z"), selectTool: getEffectiveShortcutLabel("tools.selectTool", "V"), diff --git a/frontend/src/components/MenuBar.tsx b/frontend/src/components/MenuBar.tsx index 644ed55..b0815c7 100644 --- a/frontend/src/components/MenuBar.tsx +++ b/frontend/src/components/MenuBar.tsx @@ -448,18 +448,16 @@ export function MenuBar() { }, { label: "Zoom In", - shortcut: shortcut("view.zoomIn", "Ctrl++"), + shortcut: shortcut("view.zoomIn", "+"), onClick: () => { - const { pixelsPerSecond, setZoom } = useDAWStore.getState(); - setZoom(Math.min(pixelsPerSecond * 1.5, 500)); + getRegisteredAction("view.zoomIn")?.execute(); }, }, { label: "Zoom Out", - shortcut: shortcut("view.zoomOut", "Ctrl+-"), + shortcut: shortcut("view.zoomOut", "-"), onClick: () => { - const { pixelsPerSecond, setZoom } = useDAWStore.getState(); - setZoom(Math.max(pixelsPerSecond / 1.5, 10)); + getRegisteredAction("view.zoomOut")?.execute(); }, }, { diff --git a/frontend/src/components/NAMExplorer.tsx b/frontend/src/components/NAMExplorer.tsx index dafd1bc..f5de9e5 100644 --- a/frontend/src/components/NAMExplorer.tsx +++ b/frontend/src/components/NAMExplorer.tsx @@ -6933,7 +6933,9 @@ export function NAMExplorer({ const sourceFlowRackSlots = schema.uiState?.namRackSlots && typeof schema.uiState.namRackSlots === "object" ? schema.uiState.namRackSlots as Record : {}; - const sourceFlowOctaverLabel = schema.parameters.find( + const sourceFlowOctaverLabel = schema.parameters.some((parameter) => parameter.id === "fuzzEnabled") + ? "Octaver Fuzz" + : schema.parameters.find( (parameter) => parameter.id === "octaverEnabled", )?.label ?? "Octaver"; const sourceFlowRoute = sourceFlow diff --git a/frontend/src/components/NAMPresetManagerModal.tsx b/frontend/src/components/NAMPresetManagerModal.tsx index e19b6c3..f7f961b 100644 --- a/frontend/src/components/NAMPresetManagerModal.tsx +++ b/frontend/src/components/NAMPresetManagerModal.tsx @@ -43,6 +43,7 @@ export type NAMPresetManagerUserPreset = { favorite: boolean; lastUsed?: number; active: boolean; + profileMismatch?: { stored: "guitar" | "bass"; labelled: "guitar" | "bass" }; }; type SelectedPreset = @@ -79,6 +80,7 @@ type NAMPresetManagerModalProps = { onEditNotes: (name: string) => void | Promise; onExportUser: (name: string) => void | Promise; onDuplicateUser: (name: string) => void | Promise; + onCopyLabelledProfile?: (name: string) => void | Promise; onRenameUser: (name: string) => void | Promise; onDeleteUser: (name: string) => void | Promise; }; @@ -136,6 +138,7 @@ export function NAMPresetManagerModal({ onEditNotes, onExportUser, onDuplicateUser, + onCopyLabelledProfile, onRenameUser, onDeleteUser, }: NAMPresetManagerModalProps) { @@ -389,9 +392,11 @@ export function NAMPresetManagerModal({ {entry.name} - {entry.folder}{entry.tags.length > 0 ? ` · ${entry.tags.slice(0, 3).join(" · ")}` : ""} + {entry.profileMismatch + ? `Label: ${entry.profileMismatch.labelled === "bass" ? "Bass" : "Guitar"} · Saved sound: ${entry.profileMismatch.stored === "bass" ? "Bass" : "Guitar"}` + : <>{entry.folder}{entry.tags.length > 0 ? ` · ${entry.tags.slice(0, 3).join(" · ")}` : ""}} - User + {entry.profileMismatch ? "Review profile" : "User"} + {entry.profileMismatch && onCopyLabelledProfile && ( + + + + )} @@ -467,6 +479,22 @@ export function NAMPresetManagerModal({ {selectedUser && ( <>

{selectedUser.notes || "A complete saved NAM Rack preset."}

+ {selectedUser.profileMismatch && ( +
+ Review instrument profile +

+ Label: {selectedUser.profileMismatch.labelled === "bass" ? "Bass" : "Guitar"}. Saved sound: {selectedUser.profileMismatch.stored === "bass" ? "Bass" : "Guitar"}. + {" "}Loading keeps the saved sound. If the label was intended, create a separate copy to audition that profile. +

+ {onCopyLabelledProfile && ( + + )} +
+ )}
Collection
{selectedUser.folder}
Last used
{formatLastUsed(selectedUser.lastUsed)}
diff --git a/frontend/src/components/NAMRackDesignPort.css b/frontend/src/components/NAMRackDesignPort.css index c2ff06f..50463c5 100644 --- a/frontend/src/components/NAMRackDesignPort.css +++ b/frontend/src/components/NAMRackDesignPort.css @@ -416,6 +416,42 @@ text-shadow: 0 1px rgba(255,255,255,.42); } +.nam-rack-design-port .octaver-fuzz-faceplate { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 2; + pointer-events: none; + user-select: none; +} +.nam-rack-design-port .octaver-fuzz :is(.fuzz-voice-display, .fuzz-clean-readout) { + font-size: 7px; + background: transparent; + border: 0; + box-shadow: none; +} +.nam-rack-design-port .octaver-fuzz .fuzz-clean-readout { + font-variant-numeric: tabular-nums; +} +.nam-rack-design-port .nam-fuzz-clean-entry { + position: absolute; + left: var(--clean-entry-x); + top: var(--clean-entry-y); + width: 44px; + height: 26px; + transform: translate(-50%, -50%); + z-index: 45; + appearance: textfield; +} +.nam-rack-design-port .nam-fuzz-clean-entry::-webkit-inner-spin-button, +.nam-rack-design-port .nam-fuzz-clean-entry::-webkit-outer-spin-button { + appearance: none; +} +.nam-rack-design-port .nam-fuzz-clean-control[data-editing="true"] :is(.asset-control, .knob-position-indicator, .control-hit) { + visibility: hidden; +} + /* Premium NAM Rack: the active runtime shell. Kept as a final, tightly scoped layer so the legacy design boards remain available to the source-flow view. */ diff --git a/frontend/src/components/NAMRackDesignPort.tsx b/frontend/src/components/NAMRackDesignPort.tsx index 400319a..a63f89c 100644 --- a/frontend/src/components/NAMRackDesignPort.tsx +++ b/frontend/src/components/NAMRackDesignPort.tsx @@ -6,6 +6,7 @@ import "./NAMRackHardware.css"; import "./NAMRackDesignPortSourceFlow.css"; import "./NAMRackFooter.css"; import "./NAMRackHeader.css"; +import octaverFuzzFaceplate from "../assets/nam/design/bodies/octaver-fuzz-faceplate.svg"; import { type CSSProperties, createContext, @@ -17,6 +18,7 @@ import { useCallback, useContext, useEffect, + useId, useMemo, useRef, useState, @@ -62,6 +64,7 @@ import type { NAMRackOversamplingFactor, RackModuleId, RackSectionId } from "./N import type { NAMRackCabMode } from "./NAMCabPresentation"; import type { NAMRackAdvancedStageId } from "./NAMRackMixer"; import { namMeterFraction } from "../utils/namMeterLevel"; +import { NAM_FUZZ_VOICE_LABELS, normalizeNAMFuzzVoice, resolveNAMFuzzCleanEntry } from "../utils/namOctaverFuzz"; import { observeTONE3000AppendSentinel } from "../utils/tone3000InfiniteAppend"; import { NAMRackControlTooltip } from "./NAMRackControlTooltip"; import { @@ -385,23 +388,21 @@ export function namPedalHardwareSizePercent( } export const NAM_PRE_SIGNAL_LAYOUT = { - // Five independent pedals occupy the same 748 px row that previously held - // Tape Echo. EQ Boost is immediately before Precision Drive in the signal - // path, while the wider 918 px artboard keeps the complete row centred. - 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 }, + // Octaver Fuzz shares the 156 px enclosure standard. + // Keep the five-pedal row centred on the unchanged 918 px artboard. + compressor: { x: 67, y: 42, w: 156, h: 232 }, + octaver: { x: 233, y: 42, w: 156, h: 232 }, + eqBoost: { x: 399, y: 42, w: 156, h: 232 }, + precisionDrive: { x: 565, y: 42, w: 120, h: 232 }, + distortion: { x: 695, y: 42, w: 156, h: 232 }, } as const satisfies Record; export const NAM_PRE_LOGICAL_SURFACE = { width: 918, height: 341, - row: { x: 85, y: 3, w: 748, h: 271 }, - // Fit and overflow must be computed from the same row. The former mismatched - // 748/898 widths manufactured a scrollbar even when every pedal was visible. - scaleReference: { x: 85, y: 3, w: 748, h: 271 }, + row: { x: 67, y: 42, w: 784, h: 232 }, + // Fit and overflow must use the same complete row. + scaleReference: { x: 67, y: 42, w: 784, h: 232 }, } as const; export const NAM_PRE_FX_HARDWARE_LAYOUT = { @@ -900,6 +901,31 @@ export const NAM_COMPRESSOR_FACEPLATE_LAYOUT = { }, } as const; +export const NAM_OCTAVER_FUZZ_FACEPLATE_LAYOUT = { + // Source coordinates match the dedicated 156x232 vector silkscreen asset. + // The seven rotaries and both switches retain separate interactive envelopes. + cleanKnob: { x: (34 / 156) * 100, y: (37 / 232) * 100, size: (22 / 156) * 100, hitSize: (37 / 156) * 100 }, + cleanReadout: { x: (13 / 156) * 100, y: (51 / 232) * 100, w: (42 / 156) * 100, h: (12 / 232) * 100 }, + columns: [22, 50, 78], + voiceDisplay: { x: (83 / 156) * 100, y: (51 / 232) * 100, w: (58 / 156) * 100, h: (12 / 232) * 100 }, + voiceSelector: { x: (112 / 156) * 100, y: (37 / 232) * 100, size: (NAM_THREE_POSITION_SELECTOR_PX / 156) * 100, hitSize: (37 / 156) * 100 }, + topY: (83 / 232) * 100, + lowerY: (127 / 232) * 100, + knobSize: NAM_PRE_FX_HARDWARE_LAYOUT.octaver.knobSize, + knobHitSize: (37 / 156) * 100, + titleY: (164 / 232) * 100, + powerXs: [28, 72], + ledY: 76.8, + footY: 89, + footHitSize: (37 / 156) * 100, + // Named painted zones exclude the photograph's transparent padding and rim. + safeZones: { + header: { x: 14, y: 18, w: 128, h: 46 }, + knobs: { x: 14, y: 64, w: 128, h: 91 }, + footer: { x: 24, y: 170, w: 108, h: 56 }, + }, +} as const; + export const NAM_THREE_POSITION_SELECTOR_ROTATIONS = [-52, 0, 52] as const; export const NAM_THREE_POSITION_SELECTOR_DETENT_RADIUS_PERCENT = 45.3; export const NAM_REVERB_VOICE_SELECTOR_ROTATIONS = [-60, -20, 20, 60] as const; @@ -1654,6 +1680,8 @@ function AssetControl({ hardwareKind, panelRotaryVariant, exactSizeVariant, + onRequestValueEntry, + describedBy, }: { assetId: NAMDesignControlAssetId; className: string; @@ -1686,6 +1714,8 @@ function AssetControl({ | "panel-toggle" | "panel-knob" | "panel-led"; + onRequestValueEntry?: () => void; + describedBy?: string; }) { const param = useBoundDesignParam(paramId); const context = useContext(DesignParamContext); @@ -1755,7 +1785,8 @@ function AssetControl({ ); const showsDisabledFeedback = Boolean(!allowInteraction && disabledReason); const showsControlFeedback = showsLiveFeedback || showsDisabledFeedback; - const feedbackValue = showsDisabledFeedback ? disabledReason : valueLabel; + const feedbackValue = showsDisabledFeedback ? disabledReason + : onRequestValueEntry ? `${valueLabel} · Enter or right-click to type` : valueLabel; const clearFeedbackTimer = useCallback(() => { if (feedbackTimerRef.current !== null) { @@ -1980,7 +2011,11 @@ function AssetControl({ showFeedbackNow(); const arrowMultiplier = event.shiftKey || event.ctrlKey || event.metaKey ? 1 : 4; - if ((event.key === "Enter" || event.key === " ") && isButtonLike) { + if (event.key === "Enter" && onRequestValueEntry) { + event.preventDefault(); + event.stopPropagation(); + onRequestValueEntry(); + } else if ((event.key === "Enter" || event.key === " ") && isButtonLike) { event.preventDefault(); event.stopPropagation(); if (onButtonClick) onButtonClick(param, commitParamValue); @@ -2017,6 +2052,7 @@ function AssetControl({ isButtonLike, isToggleButton, onButtonClick, + onRequestValueEntry, param, showFeedbackNow, stepParam, @@ -2071,6 +2107,8 @@ function AssetControl({ : undefined } aria-disabled={showsDisabledFeedback || undefined} + aria-describedby={describedBy} + aria-keyshortcuts={onRequestValueEntry ? "Enter" : undefined} aria-checked={ interactive && isSwitchControl && param ? toggleActive : undefined } @@ -2124,6 +2162,11 @@ function AssetControl({ onClick={handleClick} onWheel={handleWheel} onDoubleClick={handleDoubleClick} + onContextMenu={onRequestValueEntry && interactive ? (event) => { + event.preventDefault(); + event.stopPropagation(); + onRequestValueEntry(); + } : undefined} onKeyDown={handleKeyDown} /> )} @@ -2590,11 +2633,13 @@ function ThreePositionRotarySelector({ x, y, size, + hitSize = size * 1.28, paramId, }: { x: number; y: number; size: number; + hitSize?: number; paramId: string; }) { const param = useBoundDesignParam(paramId); @@ -2641,7 +2686,7 @@ function ThreePositionRotarySelector({ size={size} paramId={paramId} interaction="button" - hitSize={size * 1.28} + hitSize={hitSize} enableButtonDrag stateRotations={NAM_THREE_POSITION_SELECTOR_ROTATIONS} onButtonClick={(param, commitValue) => { @@ -2724,6 +2769,11 @@ function ReverbVoiceDisplay({ paramId }: { paramId: string }) { ); } +function FuzzVoiceDisplay() { + const param = useBoundDesignParam("fuzzVoice"); + return ; +} + function CompressorHPFReadout({ x, y, @@ -4526,16 +4576,103 @@ function AmpCaptureSelector({ ); } +function FuzzCleanKnob() { + const param = useBoundDesignParam("fuzzCleanMix"); + const enabled = useBoundDesignParam("fuzzEnabled"); + const commitValue = useDesignParamCommit(param); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const editingRef = useRef(false); + const restoreFocusRef = useRef(false); + const controlRef = useRef(null); + const descriptionId = useId(); + const layout = NAM_OCTAVER_FUZZ_FACEPLATE_LAYOUT; + const percent = Math.round((param?.value ?? 0) * 1000) / 10; + + useEffect(() => { + if (!editing && restoreFocusRef.current) { + restoreFocusRef.current = false; + controlRef.current?.querySelector('[role="slider"]')?.focus(); + } + }, [editing]); + + if (!param) return null; + const openEntry = () => { + setDraft(String(percent)); + editingRef.current = true; + setEditing(true); + }; + const finishEntry = (commit: boolean, restoreFocus: boolean) => { + if (!editingRef.current) return; + editingRef.current = false; + if (commit) { + const entry = resolveNAMFuzzCleanEntry(param, draft); + if (entry.value !== null) commitValue(entry.value); + } + restoreFocusRef.current = restoreFocus; + setEditing(false); + }; + + return ( + + + {percent}% + + Blend the signal entering Fuzz, including active Direct, Down and Up voices, + with the fuzz sound. Enter or right-click to type a percentage; double-click + to reset. Arrow keys adjust; Shift adjusts finely. + {(enabled?.value ?? 0) < 0.5 ? " Fuzz is bypassed. This blend is saved for when you engage Fuzz." : " Fuzz is enabled."} + {" "}Level controls the entire blend before PRE EQ, Drive, Distortion, + Pedal Capture and Amp. At 100% Clean and 0 dB Level, Fuzz passes its incoming signal unchanged. + {" "}The rack Gate detects input before the pedals and applies its gain + after the pedals and models, before Cabinet. Adjust its Threshold and + Release for the input noise; raising Fuzz Level can drive later stages harder. + + {editing && ( + event.currentTarget.select()} + onChange={(event) => setDraft(event.currentTarget.value)} + onBlur={() => finishEntry(true, false)} + onPointerDown={(event) => event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter" || event.key === "Escape") { + event.preventDefault(); + finishEntry(event.key === "Enter", true); + } + }} + /> + )} + + ); +} + function PreFxStage({ compressorGainReductionDb = 0, }: { compressorGainReductionDb?: number; }) { - const octaverParam = useBoundDesignParam("octaverEnabled"); const instrumentProfile = useBoundDesignParam("instrumentProfile"); - const octaverLabel = octaverParam?.label ?? "Octaver"; - const octaverHardwareTitle = - octaverLabel === "Stereo Poly Octaver" ? "POLY OCTAVER" : "OCTAVER"; + const octaverHardwareTitle = "OCTAVER FUZZ"; + const octaverLayout = NAM_OCTAVER_FUZZ_FACEPLATE_LAYOUT; const eqBoostLayout = NAM_EQ_BOOST_FACEPLATE_LAYOUT; const driveLayout = NAM_PRECISION_DRIVE_FACEPLATE_LAYOUT; const preEqBands = namPreEqBandsForProfile(instrumentProfile?.value); @@ -4642,61 +4779,60 @@ function PreFxStage({ value="Compressor on / off" /> - + + + + - - - + {(["fuzzDrive", "fuzzTone", "fuzzLevelDb"] as const).map((paramId, index) => ( + + ))} + {(["octaverEnabled", "fuzzEnabled"] as const).map((paramId, index) => ( + + + + + ))} + void; }) { - const [compactDetailsOpen, setCompactDetailsOpen] = useState(false); + // Let the native details element own manual toggles. Its queued toggle event + // must not overwrite a reveal requested by an async capture result. + const compactDetailsRef = useRef(null); + const compactDetailsRevealPending = useRef(false); + const revealCompactDetails = useCallback(() => { + if (compactDetailsRef.current) compactDetailsRef.current.open = true; + else compactDetailsRevealPending.current = true; + }, []); + const attachCompactDetails = useCallback((element: HTMLDetailsElement | null) => { + compactDetailsRef.current = element; + if (element && compactDetailsRevealPending.current) { + element.open = true; + compactDetailsRevealPending.current = false; + } + }, []); const emit = ( action: NAMSourceFlowDesignActionId, value = "", rowId = "", ) => { - if (action === "select-row") setCompactDetailsOpen(true); + if (action === "select-row") revealCompactDetails(); onAction({ type: "nam-source-flow-design-port", instanceId: "native-source-flow", @@ -6224,8 +6374,8 @@ function SourceFlowSurface({ const appendSentinelRef = useRef(null); useEffect(() => { if (config.captures?.items.length || config.captures?.busy || config.captures?.error) - setCompactDetailsOpen(true); - }, [config.captures?.title, config.captures?.items.length, config.captures?.busy, config.captures?.error]); + revealCompactDetails(); + }, [config.captures?.title, config.captures?.items.length, config.captures?.busy, config.captures?.error, revealCompactDetails]); const actionStyle: NativeStyle = { "--tone-action-count": Math.max(1, config.actions.length), "--tone-action-width": `${config.actions.length * 118 + Math.max(0, config.actions.length - 1) * 8}px`, @@ -6518,8 +6668,7 @@ function SourceFlowSurface({ {config.selectedAvailable ? (
setCompactDetailsOpen(event.currentTarget.open)} + ref={attachCompactDetails} > @@ -7575,7 +7724,10 @@ export function NAMRackDesignPort({