From a5d3df22dfed3600a8c45c8f1cee23c2c40c7a8f Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Fri, 12 Jun 2026 20:26:35 +0100 Subject: [PATCH 01/27] Copy build artefacts to working executable dir --- Jamma/Jamma.vcxproj | 10 ++++++++-- Jamma/src/Main.cpp | 20 ++++++++++++++++++++ JammaLib/JammaLib.vcxproj | 8 ++++---- test/JammaLib_Tests/JammaLib_Tests.vcxproj | 4 ++-- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Jamma/Jamma.vcxproj b/Jamma/Jamma.vcxproj index 33301fc3..d5104364 100644 --- a/Jamma/Jamma.vcxproj +++ b/Jamma/Jamma.vcxproj @@ -133,9 +133,12 @@ Windows - $(ProjectDir)..\lib\vst2sdk\x64\Debug\MD;$(ProjectDir)..\lib\njclient\x64\Debug\MD;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib\manual-link;$(SolutionDir)JammaLib\bin\x64\Debug;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib\vst3sdk;%(AdditionalLibraryDirectories) + $(ProjectDir)..\lib\vst2sdk\x64\Debug\MD;$(ProjectDir)..\lib\njclient\x64\Debug\MD;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib\manual-link;$(SolutionDir)JammaLib\bin\x64\Debug;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib\vst3sdk;%(AdditionalLibraryDirectories) vst2sdk.lib;njclient.lib;ogg.lib;vorbis.lib;vorbisenc.lib;vorbisfile.lib;ws2_32.lib;JammaLib.lib;sdk_hosting.lib;sdk.lib;sdk_common.lib;pluginterfaces.lib;base.lib;opengl32.lib;Comctl32.lib;%(AdditionalDependencies) + + copy /Y "$(SolutionDir)vcpkg_installed\x64-windows\debug\bin\ogg.dll" "$(OutDir)" copy /Y "$(SolutionDir)vcpkg_installed\x64-windows\debug\bin\vorbis.dll" "$(OutDir)" copy /Y "$(SolutionDir)vcpkg_installed\x64-windows\debug\bin\vorbisenc.dll" "$(OutDir)" + @@ -180,9 +183,12 @@ Windows true true - $(ProjectDir)..\lib\vst2sdk\x64\Release\MD;$(ProjectDir)..\lib\njclient\x64\Release\MD;$(SolutionDir)vcpkg_installed\x64-windows\lib\manual-link;$(SolutionDir)JammaLib\bin\x64\Release;$(ProjectDir)lib\x64;$(SolutionDir)vcpkg_installed\x64-windows\lib\vst3sdk;%(AdditionalLibraryDirectories) + $(ProjectDir)..\lib\vst2sdk\x64\Release\MD;$(ProjectDir)..\lib\njclient\x64\Release\MD;$(SolutionDir)vcpkg_installed\x64-windows\lib;$(SolutionDir)vcpkg_installed\x64-windows\lib\manual-link;$(SolutionDir)JammaLib\bin\x64\Release;$(ProjectDir)lib\x64;$(SolutionDir)vcpkg_installed\x64-windows\lib\vst3sdk;%(AdditionalLibraryDirectories) vst2sdk.lib;njclient.lib;ogg.lib;vorbis.lib;vorbisenc.lib;vorbisfile.lib;ws2_32.lib;JammaLib.lib;sdk_hosting.lib;sdk.lib;sdk_common.lib;pluginterfaces.lib;base.lib;opengl32.lib;Comctl32.lib;%(AdditionalDependencies) + + copy /Y "$(SolutionDir)vcpkg_installed\x64-windows\bin\ogg.dll" "$(OutDir)" copy /Y "$(SolutionDir)vcpkg_installed\x64-windows\bin\vorbis.dll" "$(OutDir)" copy /Y "$(SolutionDir)vcpkg_installed\x64-windows\bin\vorbisenc.dll" "$(OutDir)" + diff --git a/Jamma/src/Main.cpp b/Jamma/src/Main.cpp index bdd23d83..a447e07a 100644 --- a/Jamma/src/Main.cpp +++ b/Jamma/src/Main.cpp @@ -170,6 +170,23 @@ namespace return DefaultIniPath(); } + + bool IsWindowPlacementVisible(const utils::Position2d& position, const utils::Size2d& size) + { + RECT workArea{}; + if (!SystemParametersInfo(SPI_GETWORKAREA, 0, &workArea, 0)) + return true; + + const LONG left = static_cast(position.X); + const LONG top = static_cast(position.Y); + const LONG right = left + static_cast(size.Width); + const LONG bottom = top + static_cast(size.Height); + + return right > workArea.left + && bottom > workArea.top + && left < workArea.right + && top < workArea.bottom; + } } void SetupConsole() @@ -318,6 +335,9 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmd sceneParams.Position = defaults.value().WinPos; sceneParams.Size = defaults.value().WinSize; + if (!IsWindowPlacementVisible(sceneParams.Position, sceneParams.Size)) + sceneParams.Position = Window::Center(sceneParams.Size); + std::stringstream ss; InitFile::ToStream(defaults.value(), ss); diff --git a/JammaLib/JammaLib.vcxproj b/JammaLib/JammaLib.vcxproj index 78cd8498..9be1eb44 100644 --- a/JammaLib/JammaLib.vcxproj +++ b/JammaLib/JammaLib.vcxproj @@ -100,7 +100,7 @@ true true WIN32;NDEBUG;NOMINMAX;_LIB;GLEW_STATIC;_CRT_SECURE_NO_WARNINGS;__WINDOWS_DS__;__WINDOWS_ASIO__;__WINDOWS_MM__;__STDC_LIB_EXT1__;JAMMA_VST3_ENABLED;JAMMA_VST2_ENABLED;%(PreprocessorDefinitions) - $(ProjectDir)..\lib\vst2sdk;$(ProjectDir)..\lib\njclient;$(ProjectDir)..\lib;$(ProjectDir)include;$(ProjectDir)src\base;$(ProjectDir)src\utils;$(ProjectDir)lib\opengl;$(ProjectDir)lib;$(ProjectDir)lib\rtaudio\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include\vst3sdk;%(AdditionalIncludeDirectories) + $(ProjectDir)..\lib\vst2sdk;$(ProjectDir)..\lib\njclient;$(ProjectDir)..\lib;$(ProjectDir)include;$(ProjectDir)src\base;$(ProjectDir)src\utils;$(ProjectDir)lib\opengl;$(ProjectDir)lib;$(ProjectDir)lib\rtaudio\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include\vst3sdk;%(AdditionalIncludeDirectories) Windows @@ -125,7 +125,7 @@ Disabled true WIN32;_DEBUG;NOMINMAX;_LIB;GLEW_STATIC;_CRT_SECURE_NO_WARNINGS;__WINDOWS_DS__;__WINDOWS_ASIO__;__WINDOWS_MM__;__STDC_LIB_EXT1__;JAMMA_VST3_ENABLED;JAMMA_VST2_ENABLED;%(PreprocessorDefinitions) - $(ProjectDir)..\lib\vst2sdk;$(ProjectDir)..\lib\njclient;$(ProjectDir)..\lib;$(ProjectDir)include;$(ProjectDir)src\base;$(ProjectDir)src\utils;$(ProjectDir)lib\opengl;$(ProjectDir)lib;$(ProjectDir)lib\rtaudio\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include\vst3sdk;%(AdditionalIncludeDirectories) + $(ProjectDir)..\lib\vst2sdk;$(ProjectDir)..\lib\njclient;$(ProjectDir)..\lib;$(ProjectDir)include;$(ProjectDir)src\base;$(ProjectDir)src\utils;$(ProjectDir)lib\opengl;$(ProjectDir)lib;$(ProjectDir)lib\rtaudio\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include\vst3sdk;%(AdditionalIncludeDirectories) true include\stdafx.h /FS %(AdditionalOptions) @@ -155,7 +155,7 @@ Disabled true _DEBUG;NOMINMAX;_LIB;GLEW_STATIC;_CRT_SECURE_NO_WARNINGS;__WINDOWS_DS__;__WINDOWS_ASIO__;__WINDOWS_MM__;__STDC_LIB_EXT1__;JAMMA_VST3_ENABLED;JAMMA_VST2_ENABLED;%(PreprocessorDefinitions) - $(ProjectDir)..\lib\vst2sdk;$(ProjectDir)..\lib\njclient;$(ProjectDir)..\lib;$(ProjectDir)include;$(ProjectDir)src\base;$(ProjectDir)src\utils;$(ProjectDir)lib\opengl;$(ProjectDir)lib;$(ProjectDir)lib\rtaudio\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include\vst3sdk;%(AdditionalIncludeDirectories) + $(ProjectDir)..\lib\vst2sdk;$(ProjectDir)..\lib\njclient;$(ProjectDir)..\lib;$(ProjectDir)include;$(ProjectDir)src\base;$(ProjectDir)src\utils;$(ProjectDir)lib\opengl;$(ProjectDir)lib;$(ProjectDir)lib\rtaudio\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include\vst3sdk;%(AdditionalIncludeDirectories) true include\stdafx.h /FS %(AdditionalOptions) @@ -186,7 +186,7 @@ true true NDEBUG;NOMINMAX;_LIB;GLEW_STATIC;_CRT_SECURE_NO_WARNINGS;__WINDOWS_DS__;__WINDOWS_ASIO__;__WINDOWS_MM__;__STDC_LIB_EXT1__;JAMMA_VST3_ENABLED;JAMMA_VST2_ENABLED;%(PreprocessorDefinitions) - $(ProjectDir)..\lib\vst2sdk;$(ProjectDir)..\lib\njclient;$(ProjectDir)..\lib;$(ProjectDir)include;$(ProjectDir)src\base;$(ProjectDir)src\utils;$(ProjectDir)lib\opengl;$(ProjectDir)lib;$(ProjectDir)lib\rtaudio\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include\vst3sdk;%(AdditionalIncludeDirectories) + $(ProjectDir)..\lib\vst2sdk;$(ProjectDir)..\lib\njclient;$(ProjectDir)..\lib;$(ProjectDir)include;$(ProjectDir)src\base;$(ProjectDir)src\utils;$(ProjectDir)lib\opengl;$(ProjectDir)lib;$(ProjectDir)lib\rtaudio\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include;$(VcpkgInstalledDir)$(VcpkgTriplet)\include\vst3sdk;%(AdditionalIncludeDirectories) true include\stdafx.h %(AdditionalOptions) diff --git a/test/JammaLib_Tests/JammaLib_Tests.vcxproj b/test/JammaLib_Tests/JammaLib_Tests.vcxproj index 3a139f5c..d92288b6 100644 --- a/test/JammaLib_Tests/JammaLib_Tests.vcxproj +++ b/test/JammaLib_Tests/JammaLib_Tests.vcxproj @@ -96,7 +96,7 @@ true Console vst2sdk.lib;njclient.lib;ogg.lib;vorbis.lib;vorbisenc.lib;vorbisfile.lib;ws2_32.lib;gtest_main.lib;gtest.lib;sdk_hosting.lib;sdk.lib;sdk_common.lib;pluginterfaces.lib;base.lib;opengl32.lib;Comctl32.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) - $(ProjectDir)..\..\lib\vst2sdk\x64\Debug\MD;$(ProjectDir)..\..\lib\njclient\x64\Debug\MD;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib\vst3sdk;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib\manual-link;%(AdditionalLibraryDirectories) + $(ProjectDir)..\..\lib\vst2sdk\x64\Debug\MD;$(ProjectDir)..\..\lib\njclient\x64\Debug\MD;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib\vst3sdk;$(SolutionDir)vcpkg_installed\x64-windows\debug\lib\manual-link;%(AdditionalLibraryDirectories) xcopy /y "$(VcpkgInstalledDir)$(VcpkgTriplet)\debug\bin\gtest.dll" "$(OutDir)" @@ -147,7 +147,7 @@ xcopy /y "$(VcpkgInstalledDir)$(VcpkgTriplet)\debug\bin\gtest_main.dll" "$(OutDi true true vst2sdk.lib;njclient.lib;ogg.lib;vorbis.lib;vorbisenc.lib;vorbisfile.lib;ws2_32.lib;gtest_main.lib;gtest.lib;sdk_hosting.lib;sdk.lib;sdk_common.lib;pluginterfaces.lib;base.lib;opengl32.lib;Comctl32.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) - $(ProjectDir)..\..\lib\vst2sdk\x64\Release\MD;$(ProjectDir)..\..\lib\njclient\x64\Release\MD;$(SolutionDir)vcpkg_installed\x64-windows\lib\vst3sdk;$(SolutionDir)vcpkg_installed\x64-windows\lib\manual-link;%(AdditionalLibraryDirectories) + $(ProjectDir)..\..\lib\vst2sdk\x64\Release\MD;$(ProjectDir)..\..\lib\njclient\x64\Release\MD;$(SolutionDir)vcpkg_installed\x64-windows\lib;$(SolutionDir)vcpkg_installed\x64-windows\lib\vst3sdk;$(SolutionDir)vcpkg_installed\x64-windows\lib\manual-link;%(AdditionalLibraryDirectories) From 3d1050be07647e52a0cb075c7447c72f56faf778 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Fri, 12 Jun 2026 20:26:46 +0100 Subject: [PATCH 02/27] Remove unneeded scripts --- scripts/bootstrap.cmd | 2 - scripts/run-vst-editor-debug.ps1 | 112 ------------------------------- 2 files changed, 114 deletions(-) delete mode 100644 scripts/bootstrap.cmd delete mode 100644 scripts/run-vst-editor-debug.ps1 diff --git a/scripts/bootstrap.cmd b/scripts/bootstrap.cmd deleted file mode 100644 index 6152fd88..00000000 --- a/scripts/bootstrap.cmd +++ /dev/null @@ -1,2 +0,0 @@ -% Get dependencies from submodules -git clone --recursive \ No newline at end of file diff --git a/scripts/run-vst-editor-debug.ps1 b/scripts/run-vst-editor-debug.ps1 deleted file mode 100644 index 6140ce45..00000000 --- a/scripts/run-vst-editor-debug.ps1 +++ /dev/null @@ -1,112 +0,0 @@ -param( - [string]$Configuration = "Debug", - [string]$Platform = "x64", - [string]$DefaultsPath = "", - [string]$ExecutablePath = "", - [string]$LogPath = "", - [int]$StationIndex = 0, - [int]$PluginIndex = 0, - [int]$TimeoutSeconds = 20, - [switch]$NoAutoOpen, - [switch]$NoFileLog -) - -$ErrorActionPreference = "Stop" - -$repoRoot = Split-Path -Parent $PSScriptRoot -if ([string]::IsNullOrWhiteSpace($ExecutablePath)) { - $ExecutablePath = Join-Path $repoRoot ("Jamma\bin\{0}\{1}\Jamma.exe" -f $Platform, $Configuration) -} - -if ([string]::IsNullOrWhiteSpace($LogPath)) { - $LogPath = Join-Path $env:APPDATA "Jamma\vst-diagnostic.log" -} - -$artifactDir = Join-Path $repoRoot "artifacts\vst-debug" -$summaryPath = Join-Path $artifactDir "last-run-summary.txt" -$stdoutPath = Join-Path $artifactDir "last-run-stdout.txt" -$stderrPath = Join-Path $artifactDir "last-run-stderr.txt" - -New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null -if (Test-Path $LogPath) { Remove-Item $LogPath -Force } -if (Test-Path $summaryPath) { Remove-Item $summaryPath -Force } -if (Test-Path $stdoutPath) { Remove-Item $stdoutPath -Force } -if (Test-Path $stderrPath) { Remove-Item $stderrPath -Force } - -if (-not (Test-Path $ExecutablePath)) { - throw "Jamma executable not found at $ExecutablePath" -} - -$previousDefaults = $env:JAMMA_DEFAULTS_PATH -$previousAutoOpen = $env:JAMMA_VST_DEBUG_AUTO_OPEN -$previousLogToFile = $env:JAMMA_VST_DEBUG_LOG_TO_FILE -$previousLogPath = $env:JAMMA_VST_DEBUG_LOG_PATH -$previousStationIndex = $env:JAMMA_VST_DEBUG_STATION_INDEX -$previousPluginIndex = $env:JAMMA_VST_DEBUG_PLUGIN_INDEX - -try { - if (-not [string]::IsNullOrWhiteSpace($DefaultsPath)) { - $env:JAMMA_DEFAULTS_PATH = $DefaultsPath - } - $env:JAMMA_VST_DEBUG_AUTO_OPEN = if ($NoAutoOpen) { "0" } else { "1" } - $env:JAMMA_VST_DEBUG_LOG_TO_FILE = if ($NoFileLog) { "0" } else { "1" } - $env:JAMMA_VST_DEBUG_LOG_PATH = $LogPath - $env:JAMMA_VST_DEBUG_STATION_INDEX = $StationIndex.ToString() - $env:JAMMA_VST_DEBUG_PLUGIN_INDEX = $PluginIndex.ToString() - - $process = Start-Process -FilePath $ExecutablePath -PassThru -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - $opened = $false - - while (-not $process.HasExited -and (Get-Date) -lt $deadline) { - if (Test-Path $LogPath) { - $logContent = Get-Content $LogPath -Raw - if ($logContent -match "auto-open-status \| opened") { - $opened = $true - break - } - } - - Start-Sleep -Milliseconds 250 - $process.Refresh() - } - - if (-not $process.HasExited) { - Stop-Process -Id $process.Id -Force - $process.WaitForExit() - } - - $logTail = if (Test-Path $LogPath) { - (Get-Content $LogPath | Select-Object -Last 40) -join [Environment]::NewLine - } else { - "" - } - - $summary = @( - "timestamp=$(Get-Date -Format o)", - "executable=$ExecutablePath", - "defaultsPath=$DefaultsPath", - "logPath=$LogPath", - "stationIndex=$StationIndex", - "pluginIndex=$PluginIndex", - "timeoutSeconds=$TimeoutSeconds", - "autoOpenEnabled=$(-not $NoAutoOpen)", - "fileLogEnabled=$(-not $NoFileLog)", - "opened=$opened", - "exitCode=$($process.ExitCode)", - "---- log tail ----", - $logTail - ) -join [Environment]::NewLine - - Set-Content -Path $summaryPath -Value $summary - Write-Host "Summary: $summaryPath" - Write-Host "Log: $LogPath" -} -finally { - $env:JAMMA_DEFAULTS_PATH = $previousDefaults - $env:JAMMA_VST_DEBUG_AUTO_OPEN = $previousAutoOpen - $env:JAMMA_VST_DEBUG_LOG_TO_FILE = $previousLogToFile - $env:JAMMA_VST_DEBUG_LOG_PATH = $previousLogPath - $env:JAMMA_VST_DEBUG_STATION_INDEX = $previousStationIndex - $env:JAMMA_VST_DEBUG_PLUGIN_INDEX = $previousPluginIndex -} \ No newline at end of file From ab88b5dba1dc2cbb57ffd213ef8259fa00e57642 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Fri, 12 Jun 2026 20:33:29 +0100 Subject: [PATCH 03/27] Update instructions for building from fresh vcpkg --- .github/copilot-instructions.md | 201 ++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..c4eef3ab --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,201 @@ +# Jamma Copilot Instructions + +## Project + +Jamma is a Windows multichannel loop-sampling app for recording and live performance. + +- App shell: `Jamma` +- Core engine: `JammaLib` +- Native tests: `test/JammaLib_Tests` + +Treat this as a real-time audio codebase: prefer predictable, low-latency-safe behavior over clever abstractions. + +## Architecture + +- Keep engine, loop, and audio behavior in `JammaLib`; keep `Jamma` as app entry and wiring. +- Core hierarchy: `Station -> LoopTake -> Loop`. +- Audio flow: + - ADC capture -> `ChannelMixer` -> latency-compensated writes into `Station` / `LoopTake` / `Loop` + - DAC playback <- `Station` / `LoopTake` / `Loop` mix/read <- `ChannelMixer` +- Keep glue code thin and explicit. Avoid cross-subsystem coupling. + +## Build + +Environment: + +- Windows +- Visual Studio 2022 with C++ desktop workload +- Windows SDK 10.0 +- Toolset `v145`, standard `stdcpplatest`, platform `x64` + +Fresh setup: + +Assume vcpkg is cloned externally, that VCPKG environment variable is set to vcpkg repo root, and that vcpkg is bootstrapped. Then install dependencies: + +```powershell +vcpkg install +``` + +Rules: + +1. Use incremental `Build` by default. Avoid `Clean` and `Rebuild` unless necessary. +2. Build only affected projects: + - `Jamma/src` changes -> `Jamma/Jamma.vcxproj` + - `JammaLib/src` or `JammaLib/include` changes -> `JammaLib/JammaLib.vcxproj`, then dependents as needed + - `test/JammaLib_Tests/src` changes -> `test/JammaLib_Tests/JammaLib_Tests.vcxproj` +3. Use solution builds only when project targeting is unclear. +4. If you hit `C1041` PDB contention, apply `/FS` and a project-specific `ProgramDataBaseFileName` in the affected project. + +MSBuild: + +```powershell +$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" +``` + +Preferred targeted builds: + +```powershell +$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" + +# Resolve repo root by walking upward until Jamma.sln is found. +$repoRoot = (Get-Location).Path +while (-not (Test-Path (Join-Path $repoRoot "Jamma.sln"))) { + $parent = Split-Path $repoRoot -Parent + if ($parent -eq $repoRoot) { + throw "Could not find Jamma.sln. Start in this repository or set `$repoRoot explicitly." + } + $repoRoot = $parent +} + +# Always pass absolute paths. For direct .vcxproj builds, pass SolutionDir explicitly. +$sln = Join-Path $repoRoot "Jamma.sln" +$jammaLibProj = Join-Path $repoRoot "JammaLib\JammaLib.vcxproj" +$jammaProj = Join-Path $repoRoot "Jamma\Jamma.vcxproj" +$testsProj = Join-Path $repoRoot "test\JammaLib_Tests\JammaLib_Tests.vcxproj" +$solutionDirArg = "/p:SolutionDir=$($repoRoot.TrimEnd('\\'))\" + +& $msbuild $jammaLibProj /m /t:Build /p:Configuration=Debug /p:Platform=x64 $solutionDirArg +& $msbuild $jammaProj /m /t:Build /p:Configuration=Debug /p:Platform=x64 $solutionDirArg +& $msbuild $testsProj /m /t:Build /p:Configuration=Debug /p:Platform=x64 $solutionDirArg + +# Optional: solution build if project targeting is unclear. +# & $msbuild $sln /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:VcpkgEnableManifest=true +``` + +**Important:** For direct `.vcxproj` builds, derive repo root from `Jamma.sln` and pass `/p:SolutionDir=\` with exactly one trailing backslash. Do not use `$(pwd)` or a doubled trailing slash; mismatched `SolutionDir` text can invalidate `.tlog` state and trigger full test recompiles. + +### Running tests: + +Use solution builds sparingly: + +```powershell +$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" + +$repoRoot = (Get-Location).Path +while (-not (Test-Path (Join-Path $repoRoot "Jamma.sln"))) { + $parent = Split-Path $repoRoot -Parent + if ($parent -eq $repoRoot) { + throw "Could not find Jamma.sln. Start in this repository or set `$repoRoot explicitly." + } + $repoRoot = $parent +} + +$testsProj = Join-Path $repoRoot "test\JammaLib_Tests\JammaLib_Tests.vcxproj" +$testsExe = Join-Path $repoRoot "test\JammaLib_Tests\bin\x64\Debug\JammaLib_Tests.exe" +$solutionDirArg = "/p:SolutionDir=$($repoRoot.TrimEnd('\\'))\" + +& $msbuild $testsProj /m /t:Build /p:Configuration=Debug /p:Platform=x64 $solutionDirArg +& $testsExe +``` + +## Tests + +- For behavior changes in `JammaLib`, add or update tests when practical. +- Build and run tests with: + +```powershell +& $msbuild test\JammaLib_Tests\JammaLib_Tests.vcxproj /m /t:Build /p:Configuration=Debug /p:Platform=x64 +& .\test\JammaLib_Tests\bin\x64\Debug\JammaLib_Tests.exe +``` + +- Run a specific test with: + +```powershell +$repoRoot = (Get-Location).Path +while (-not (Test-Path (Join-Path $repoRoot "Jamma.sln"))) { + $parent = Split-Path $repoRoot -Parent + if ($parent -eq $repoRoot) { + throw "Could not find Jamma.sln. Start in this repository or set `$repoRoot explicitly." + } + $repoRoot = $parent +} + +$testsExe = Join-Path $repoRoot "test\JammaLib_Tests\bin\x64\Debug\JammaLib_Tests.exe" +& $testsExe --gtest_filter="SuiteName.TestName" +``` + +Troubleshooting: + +- If Google Test headers or libraries are missing, verify `vcpkg integrate install`, `vcpkg install`, and that `vcpkg_installed/` contains `gtest`. +- If the test exe exits with code `1` and no output, stale Release gtest DLLs may be in the Debug output folder. Copy the Debug DLLs back in: + +```powershell +$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" + +$repoRoot = (Get-Location).Path +while (-not (Test-Path (Join-Path $repoRoot "Jamma.sln"))) { + $parent = Split-Path $repoRoot -Parent + if ($parent -eq $repoRoot) { + throw "Could not find Jamma.sln. Start in this repository or set `$repoRoot explicitly." + } + $repoRoot = $parent +} + +$sln = Join-Path $repoRoot "Jamma.sln" +& $msbuild $sln /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:VcpkgEnableManifest=true +& $msbuild $sln /m /t:Build /p:Configuration=Release /p:Platform=x64 /p:VcpkgEnableManifest=true +``` + +## Coding Guidance + +Prefer modern C++ and functional style where practical. + +Real-time audio rules for callback and hot paths: + +- No heap allocation. +- No exceptions. +- No blocking I/O or unpredictable locks. +- No heavy logging or system calls. +- Prefer the fastest safe read/write path. +- Pre-allocate and reuse buffers and resources. + +Hotpath review rule after edits in audio-thread code: + +- Manually inspect these callback-owned functions before finishing a change: `Scene::OnTick`, `Scene::AudioCallback`, `Scene::_OnAudio`, `Loop::WriteBlock`, `LoopTake::Zero`, `LoopTake::WriteBlock`, `LoopTake::EndMultiPlay`, `LoopTake::EndMultiWrite`, `LoopTake::_InputChannel`, `Station::Zero`, `Station::WriteBlock`, `Station::EndMultiPlay`, `Station::OnBlockWriteChannel`, `Station::EndMultiWrite`, `Station::OnBounce`, `Station::_InputChannel`, `Trigger::OnTick`, `NinjamConnection::ProcessAudioBlock`, and `NinjamConnection::ConsumeStereoPair`. +- Reject any addition of blocking or lock-based primitives inside those bodies, including `std::mutex`, `std::scoped_lock`, `std::lock_guard`, `std::unique_lock`, `std::condition_variable`, `EnterCriticalSection`, `WaitForSingleObject`, `SleepConditionVariableCS`, and `SleepConditionVariableSRW`. + +General guidance: + +- Prefer value semantics, pure transformations, and explicit inputs/outputs. +- Isolate side effects such as I/O, audio device access, rendering, filesystem work, and threading. +- Use `std::vector`, `std::array`, `std::string`, RAII, and smart pointers when performance allows. +- Prefer `std::optional`, `std::variant`, and strong enums over sentinel values. +- Use algorithms and ranges only when they improve clarity without hurting hot paths. +- Use `const` aggressively; pass large objects by `const&`. +- Use `constexpr` and `noexcept` when correct. + +Avoid: + +- Raw owning pointers or manual `new` and `delete` unless measurably better in hot paths. +- Hidden global mutable state. +- Monolithic functions mixing state changes, I/O, and control flow. +- Exception-driven control flow in real-time paths. + +## Change Expectations + +1. Respect subsystem ownership and existing naming. +2. Keep diffs focused and minimal. +3. Add or update tests for behavior changes when feasible. +4. Prefer readability and maintainability over template or macro cleverness. +5. Briefly document non-obvious invariants. +6. Keep hot-path optimizations explicit and maintainable. From 1aeaadb3887fd81a292de54e043331ea9f6d6fa4 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Wed, 17 Jun 2026 00:56:07 +0100 Subject: [PATCH 04/27] Add implementation plan --- .../plan-midiAutomationRecording.prompt.md | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 .vscode/plan-midiAutomationRecording.prompt.md diff --git a/.vscode/plan-midiAutomationRecording.prompt.md b/.vscode/plan-midiAutomationRecording.prompt.md new file mode 100644 index 00000000..bcbfa18b --- /dev/null +++ b/.vscode/plan-midiAutomationRecording.prompt.md @@ -0,0 +1,396 @@ +# MIDI Automation Recording, 3D Display, and VST Instrument Playback Plan + +This plan outlines the design and implementation details for adding low-latency MIDI automation recording and playback to JammaLib, accompanied by a dynamic, undulating 3D ring visualizer. + +Coding rule for this plan: constants and helper functions must be attached to the owning class or namespace, not dropped into anonymous namespaces. Prefer named class members, static class helpers, or constexpr class-scoped values over file-local anonymous helpers. + +--- + +## Agentic Workflow + +This plan is designed to be executed in **two agent sessions** with a clean engine/rendering split. + +### Session 1 — Engine Backend (Phases 1–4) + +**Covers:** VST interface, MidiLoop data model, MIDI learn + keyboard arming, audio-thread dispatch. + +**Implement strictly in order:** +1. **Phase 1** — VST interface + registry. Build clean before moving on. +2. **Phase 2** — `MidiLoop` automation data model. Data structures and interpolation only; no rendering. +3. **Phase 3** — Keyboard arming, MIDI learn hooks, `Scene::OnAction` bindings. +4. **Phase 4** — Flat dispatch list, `_RebuildAutomationDispatch`, `_RunVstBlock` loop. + +**Stop when:** +- `JammaLib` and `Jamma` build clean with no warnings introduced. +- A CC knob move during `A`-held recording writes control points into `MidiLoop::_lanes`. +- `_RebuildAutomationDispatch` populates the flat list and `_RunVstBlock` calls `SetParameter` for at least one active lane (verified by a GTest or a scoped debug trace — no rendering required). + +**Handover:** Prepend `[DONE]` to each completed section heading (e.g. `### [DONE] 1-A: ...`). If the session ends mid-phase, mark the incomplete section `[IN PROGRESS]` and add a `> **Agent note:**` blockquote immediately below it describing exactly what was finished, what file/line to resume from, and any invariants that must hold before continuing. Leave Phases 5–6 headings untouched. + +--- + +### Session 2 — Multi-Lane + Rendering (Phases 5–6) + +**Prerequisite:** Session 1 complete. Flat dispatch list is live; `SetParameter` is being called correctly. + +**Implement in order:** +1. **Phase 5** — Confirm `MaxAutomationLanes` wiring through the dispatch rebuild; add any missing `SelectedLaneIndex` / multi-lane recording paths. +2. **Phase 6** — `automation.vert` / `automation.frag` shaders, `MidiModel` VAO/VBO setup, live control-point texture uploads. + +**Stop when:** +- Both shaders compile and link. +- `MidiModel::Draw3d` renders the curtain, crown ring, and play-position indicator. +- Live recording visibly undulates the curtain in real time. + +**Handover:** Mark all completed sections `[DONE]`. Apply the same `[IN PROGRESS]` + blockquote convention for any work left mid-section. + +--- + +## Architecture Overview + +```mermaid +graph TD + classDef main fill:#2277ff,stroke:#fff,stroke-width:2px,color:#fff; + classDef subt fill:#114499,stroke:#fff,color:#fff; + classDef nonrt fill:#1a5c1a,stroke:#aaffaa,color:#fff; + + subgraph User Input + K[Keyboard Key Events] -->|L, W, A, X, brackets| SC[Scene::OnAction] + E[Mouse / VST UI GUI] -->|SetParameterAutomated| V2[audioMasterAutomate Callback] + M[MIDI Keyboard/Controller] -->|Physical CC Ingress| MR[MidiRouter::PumpMidi] + end + + subgraph State & Registry + V2 -->|Trapped Parameter| TR[vst::LastTouchedRegistry] + TR -->|Linked on Key W| ML[midi::MidiLoop AutomationRing] + MR -->|CC Msg & RecordHeld| ML + end + + subgraph Non-Audio Thread Maintenance + SC -->|Wire / Arm / Delete / Release A| RB[Station::_RebuildAutomationDispatch] + ML -->|Resolve raw ptrs & lane metadata| RB + RB -->|atomic swap release| AD[_automationDispatch flat list] + end + + subgraph Audio Thread Player + AD -->|acquire load, cursor advance| ST[Station::_RunVstBlock] + ST -->|SetParameter delta-gated| VC[vst::VstChain / Instrument] + end + + subgraph Rendering Pipeline + ML -->|Read control points| MM[graphics::MidiModel] + MM -->|Upload sparse texture| VS[automation.vert / automation.frag] + VS -->|Undulating Circular Curtain, Crown Ring, Play-Position| SCR[Screen View] + end + + class K,E,M main; + class SC,V2,MR,TR,ML,ST,VC,MM,VS,SCR subt; + class RB,AD nonrt; +``` + +--- + +## Phase 1: Track Last Touched Parameter + +### 1-A: Interface Extension `IVstPlugin` +- Modify `IVstPlugin` in [JammaLib/src/vst/IVstPlugin.h](JammaLib/src/vst/IVstPlugin.h) to expose parameter setters: + ```cpp + virtual void SetParameter(unsigned int index, float value) noexcept = 0; + virtual float GetParameter(unsigned int index) const noexcept = 0; + ``` + +### 1-B: VST2 Plugin Implementation +- Implement the methods in [JammaLib/src/vst/Vst2Plugin.h](JammaLib/src/vst/Vst2Plugin.h) and [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp): + ```cpp + void Vst2Plugin::SetParameter(unsigned int index, float value) noexcept { + if (_effect) { + _effect->setParameter(_effect, index, value); + } + } + float Vst2Plugin::GetParameter(unsigned int index) const noexcept { + return _effect ? _effect->getParameter(_effect, index) : 0.0f; + } + ``` +- Implement empty stubs in [JammaLib/src/vst/Vst3Plugin.cpp](JammaLib/src/vst/Vst3Plugin.cpp) to satisfy the compiler interface checks. + +### 1-C: Last Touched Registry +- Add a thread-safe registry in `vst` namespace inside [JammaLib/src/vst/IVstPlugin.h](JammaLib/src/vst/IVstPlugin.h): + ```cpp + struct LastTouchedParameter { + std::atomic Plugin{nullptr}; + std::atomic ParameterIndex{0u}; + std::atomic Value{0.0f}; + }; + extern LastTouchedParameter g_lastTouchedParam; + ``` +- Update `HostCallback` in [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp)'s `audioMasterAutomate` case: + ```cpp + case audioMasterAutomate: + if (self) { + g_lastTouchedParam.Plugin.store(self, std::memory_order_relaxed); + g_lastTouchedParam.ParameterIndex.store(index, std::memory_order_relaxed); + g_lastTouchedParam.Value.store(opt, std::memory_order_relaxed); + } + return 0; + ``` + +--- + +## Phase 2: In-Memory Automation Timeline inside `MidiLoop` + +### 2-A: Automation Timeline Representation +- Represent the automation as sparse control points in the loop timeline and upload them to the GPU as an $N \times 2$ lookup texture, where each texel stores: + - `R`/`x`: the automation value + - `G`/`y`: the fractional position through the loop where that value applies +- Each `MidiLoop` holds a fixed-size array of **automation lanes** (`MaxAutomationLanes = 8`). Each lane is self-contained: it owns its `AutomationMapping` metadata, its sparse control-point buffer, and its point count. This directly supports Phase 5 (multiple wirings, simultaneous recording) without any structural change later. +- In [JammaLib/src/midi/MidiLoop.h](JammaLib/src/midi/MidiLoop.h), establish: + ```cpp + struct AutomationMapping { + // Active, Channel, and CC must be read atomically together on the MIDI thread + // (CC matching) while written together on the UI thread (key W). Pack all three + // into one uint32_t so a reader always sees a consistent triple. + // Encoding: bit 16 = Active, bits [15:8] = Channel, bits [7:0] = CC; 0 = inactive. + std::atomic MatchKey{0u}; + + // Written and read on the non-audio thread only (_RebuildAutomationDispatch). + // No atomic needed. + vst::IVstPlugin* TargetPlugin{nullptr}; + unsigned int TargetParameterIndex{0u}; + + static constexpr std::uint32_t kInactive = 0u; + static constexpr std::uint32_t MakeMatchKey(std::uint8_t ch, std::uint8_t cc) noexcept { + return (1u << 16) | (static_cast(ch) << 8) | static_cast(cc); + } + bool IsActive() const noexcept { return (MatchKey.load(std::memory_order_relaxed) >> 16) & 1u; } + std::uint8_t GetChannel() const noexcept { return static_cast(MatchKey.load(std::memory_order_relaxed) >> 8); } + std::uint8_t GetCC() const noexcept { return static_cast(MatchKey.load(std::memory_order_relaxed)); } + }; + + struct AutomationLane { + AutomationMapping Mapping; + std::array, 256u> Points{}; // (frac, value) + std::size_t PointCount = 0u; + }; + + class MidiLoop { + public: + static constexpr std::size_t MaxAutomationLanes = 8u; + + // Write a value at the given fractional position into lane laneIdx. + void SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float value) noexcept; + // Cursor-advancing read on lane laneIdx: advances cursorIdx forward to the correct + // bracket, returns the linearly interpolated value. Resets cursor on loop wrap + // (detected when frac < points[cursor].frac). Amortised O(1) per block. + float GetAutomationValueAtCursor(std::size_t laneIdx, double frac, std::uint16_t& cursorIdx) const noexcept; + + AutomationLane& GetLane(std::size_t idx) noexcept { return _lanes[idx]; } + const AutomationLane& GetLane(std::size_t idx) const noexcept { return _lanes[idx]; } + + private: + std::array _lanes{}; + }; + ``` +- The CPU keeps each lane's sparse points in their native form; the GPU receives a lane's points as a compact 2-channel texture for display. Each lane uploads independently. + +### 2-B: Read/Write Interpolation Implementation +- Inside [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp): + - Preserve sparse points exactly as they are recorded. + - When a new point arrives, insert or update the point in the loop-local control-point storage. + - For audio playback, continue to evaluate the curve via linear interpolation between neighboring control points. + - For display, upload the control-point texture to the GPU and let the shader perform piecewise-linear lookup against the sparse timeline data. + +--- + +## Phase 3: Keyboard Arming and MIDI Learn Hooks + +### 3-A: Interactive State Flags +- Add flags inside [JammaLib/src/midi/MidiRouter.h](JammaLib/src/midi/MidiRouter.h): + ```cpp + namespace midi { + extern std::atomic LearnMidiCCMode; + extern std::atomic LearnedCC; // 0xffu = nothing captured yet + extern std::atomic LearnedChannel; // 0xffu = nothing captured yet + extern std::atomic AutomationRecordHeld; + extern std::atomic SelectedLaneIndex; // which lane slot W/A targets (0..MaxAutomationLanes-1) + extern std::atomic RecordTargetLoop; // loop being recorded; nullptr = all lanes play back + } + ``` +- `LearnedCC` and `LearnedChannel` are **written inside `MidiRouter::PumpMidi`** whenever a CC message arrives and `LearnMidiCCMode` is `true`. This is the CC capture step: the user moves a physical knob/slider and the system automatically captures the controller number and channel without any further key press. +- `RecordTargetLoop` is a **raw observer pointer** — its lifetime is externally guaranteed by the `LoopTake` that owns it. It must be cleared to `nullptr` before any `MidiLoop` is destroyed (handled in `LoopTake` teardown). + +### 3-B: Interactive Key Bindings +- Modify `Scene::OnAction(KeyAction)` in [JammaLib/src/engine/Scene.cpp](JammaLib/src/engine/Scene.cpp#L446): + - Key `L` — **Learn Mode** (on key-down): Toggle `LearnMidiCCMode`. When toggling **off**, also reset `LearnedCC` and `LearnedChannel` to `0xffu` so a stale capture cannot be accidentally wired on a future press. + - Key `W` — **Wire Command** (on key-down): + - Requires `LearnedCC != 0xffu` (a CC was captured) **and** `LastTouchedParam.Plugin != nullptr` (a VST parameter was touched). + - Query the hovered `MidiLoop` via the current selector. If none, do nothing. + - Write to `loop.GetLane(SelectedLaneIndex)`: set `Active = true`, `Channel`, `ControllerNumber`, `TargetPlugin`, `TargetParameterIndex` from the captured state. + - **This writes to the selected lane slot** — it does not append a new one blindly. If slot `SelectedLaneIndex` already had a wiring, it is replaced. To add a second wiring, the user first cycles to the next empty slot with `]` (see below). + - After wiring: exit learn mode (`LearnMidiCCMode = false`, clear `LearnedCC`/`LearnedChannel`), and call `Station::_RebuildAutomationDispatch()` on the non-audio thread. + - Key `X` — **Delete Lane** (on key-down): Clear `loop.GetLane(SelectedLaneIndex)` on the hovered loop — set `Active = false`, reset all fields, erase control points. Trigger `_RebuildAutomationDispatch`. + - Key `[` / `]` — **Cycle Selected Lane** (on key-down): Decrement / increment `SelectedLaneIndex` (wrapping within `0..MaxAutomationLanes-1`). The renderer should highlight the active lane so the user knows which slot they're operating on. + Key `A` — **Automation Record Mode**: Set `AutomationRecordHeld = true` and `RecordTargetLoop = hoveredLoop` **on key-down**; clear both back to `false` / `nullptr` **on key-up**, then immediately call `Station::_RebuildAutomationDispatch()`. The rebuild's `release` store on `_automationDispatch` establishes a happens-before between all MIDI thread writes to `Points` during recording and the audio thread's subsequent `acquire` load of the dispatch pointer. Without this fence, the audio thread could read stale control-point data on the first playback block. The held-key model means recording is always intentional and transient — releasing `A` triggers the fence and immediately resumes playback on that loop's lanes. + +--- + +## Phase 4: Audio Thread Playback Routing + +### 4-A: Pre-baked Flat Dispatch List (eliminates nested weak_ptr::lock chains) + +The naïve approach of walking `state.LoopTakes → weak_ptr::lock → GetMidiLoops → GetAutomation` inside `_RunVstBlock` pays O(takes × midiLoops) in atomic refcount increments, shared_ptr pointer chasing, and per-entry atomic loads — every audio block, at audio-thread priority. This is unacceptable. + +Instead, build a compact flat list on the **non-audio thread** whenever automation wiring changes, and publish it with the same double-buffer atomic-swap pattern already used for `_audioState`. + +Add to [JammaLib/src/engine/Station.h](JammaLib/src/engine/Station.h): +```cpp +struct AutomationDispatch { + vst::IVstPlugin* plugin; // raw observer — lifetime owned by VstChain + unsigned int paramIdx; + midi::MidiLoop* loop; // raw observer — lifetime owned by LoopTake + std::uint8_t laneIdx; // which lane within loop to read/write + std::uint32_t loopLengthSamps; // pre-resolved; avoids per-block takes lock + std::uint16_t cursorIdx = 0u; // playback cursor for O(1) amortised interpolation + float lastValue = -2.f; // sentinel: force first write +}; +static constexpr std::size_t MaxAutomationDispatches = 64u; + +std::atomic _automationDispatch{nullptr}; +AutomationDispatch _automationDispatchBuf[2][MaxAutomationDispatches]{}; +std::uint8_t _automationDispatchCount[2]{}; +std::uint8_t _automationDispatchBack = 0u; +``` + +Add `_RebuildAutomationDispatch()` — called on the non-audio thread whenever automation is wired or the loop set changes. It walks all takes and MIDI loops, resolves all raw pointers, and atomically publishes the new front buffer. This is the only place that traverses weak_ptrs and shared_ptrs for automation purposes. + +### 4-B: Per-loop `frac` from `blockStartSample` (not from the clock) + +`_clock->FractionalPosition()` would use `SeedSourceLength()` as the denominator — correct only for the seed loop. Overdub loops may be harmonically related but still have independent lengths. Each dispatch entry stores `loopLengthSamps` pre-resolved at build time. Per-block fractional position: + +```cpp +const double frac = (entry.loopLengthSamps > 0u) + ? std::fmod(static_cast(blockStartSample), static_cast(entry.loopLengthSamps)) + / static_cast(entry.loopLengthSamps) + : 0.0; +``` + +This is correct for all loop configurations and costs one `fmod` instead of an indirect clock method call. + +### 4-C: Playback cursor — O(1) amortised interpolation (replaces O(N) scan) + +`GetAutomationValueAtFrac` with up to 256 sparse control points costs up to 256 comparisons per active mapping per block if searching from index 0. Since playback is monotonically forward (wrapping only at loop boundaries), the bracket for the current `frac` advances by at most 1–2 control points per block in normal use. + +Store `cursorIdx` in each `AutomationDispatch`. Per block: +1. Advance cursor forward while `points[cursor+1].frac <= frac` (typically 0–2 iterations). +2. Interpolate linearly between `points[cursor]` and `points[cursor+1]`. +3. On loop wrap (detected when `frac < lastFrac`), reset cursor to 0. + +Amortised cost across a full loop playback: O(total control points) — equivalent to a single pass, not one pass per block. + +### 4-D: Delta-threshold gate — suppress redundant `SetParameter` calls + +VST2 `setParameter` is an opcode dispatch that can trigger coefficient recalculation inside the plugin on every call. On a flat or slow-moving automation curve (the common case), the value barely changes between consecutive blocks. Gate the call: + +```cpp +constexpr float automationEpsilon = 1.0f / 65536.0f; // below 16-bit parameter resolution +if (std::abs(val - entry.lastValue) > automationEpsilon) { + entry.plugin->SetParameter(entry.paramIdx, val); + entry.lastValue = val; +} +``` + +On a steady-state loop after the first pass, this reduces `SetParameter` calls to zero — the dominant cost on a playing-but-not-moving automation lane. + +### 4-E: Final audio-thread dispatch loop in `_RunVstBlock` + +With the above in place, the audio-thread path collapses to: + +```cpp +// acquire pairs with the release store in _RebuildAutomationDispatch, +// ensuring all MIDI-thread writes to Points are visible once recordHeld goes false. +const bool recordHeld = midi::AutomationRecordHeld.load(std::memory_order_acquire); +const MidiLoop* recordTarget = midi::RecordTargetLoop.load(std::memory_order_relaxed); +const auto* dispatches = _automationDispatch.load(std::memory_order_acquire); +// Derive front buffer index from which half of _automationDispatchBuf the pointer falls in. +const std::uint8_t frontIdx = dispatches ? (dispatches == _automationDispatchBuf[0] ? 0u : 1u) : 0u; +const auto count = dispatches ? _automationDispatchCount[frontIdx] : 0u; + +for (auto i = 0u; i < count; ++i) +{ + auto& entry = dispatches[i]; + // Suppress playback only for lanes on the loop that is actively being recorded. + // Other loops' lanes (different MidiLoop*) continue playing back unaffected. + if (recordHeld && entry.loop == recordTarget) continue; + + // Per-loop fractional position from block-start sample. + const double frac = (entry.loopLengthSamps > 0u) + ? std::fmod(static_cast(blockStartSample), + static_cast(entry.loopLengthSamps)) + / static_cast(entry.loopLengthSamps) + : 0.0; + + // Advance cursor forward (0–2 steps amortised); laneIdx pre-baked into entry. + const float val = entry.loop->GetAutomationValueAtCursor(entry.laneIdx, frac, entry.cursorIdx); + + // Only call into the plugin if the value actually moved. + constexpr float epsilon = 1.0f / 65536.0f; + if (std::abs(val - entry.lastValue) > epsilon) + { + entry.plugin->SetParameter(entry.paramIdx, val); + entry.lastValue = val; + } +} +``` + +No `weak_ptr::lock`. No shared_ptr deref chain. No per-entry atomic loads. One flat loop over a hot cache line. + +--- + +## Phase 5: Multiple Automation Parameters and Multi-Playback + +### 5-A: Per-Parameter Automation State +- `MidiLoop` already holds a fixed array of `AutomationLane` (capacity `MaxAutomationLanes = 8`, defined in Phase 2-A). Each lane is fully self-contained: its own `AutomationMapping` metadata, its own sparse control-point buffer, and its own point count. No structural changes to `MidiLoop` are required for multi-lane support. +- `SelectedLaneIndex` (Phase 3-A) identifies which slot the user is currently operating on. `RecordTargetLoop` (Phase 3-A) identifies which loop is actively recording. Together they make multi-lane, multi-loop operation deterministic with no ambiguity. +- This allows: + - simultaneous recording of several parameters from the same loop (press `]` to move to next lane slot, wire each with W, then hold A to record all active lanes at once since `recordHeld` suppresses all of the target loop's lanes), + - independent wiring of different CCs/controllers to different parameters (each lane stores its own `ControllerNumber`/`Channel`), + - and independent playback of each mapped parameter via the flat dispatch list. + +### 5-B: Simultaneous / Independent Recording +- When recording is active, incoming MIDI CC values should be routed to whichever automation mapping is currently selected or currently armed for that controller/channel. +- A single loop may record multiple automation lanes at once if multiple mappings are armed. +- Each mapping should keep its own timeline of sparse control points, so one parameter’s curve can evolve independently of another’s. +- The UI/interaction layer should make it clear which automation lane is currently being learned or recorded. + +### 5-C: Multi-Playback Routing +- During playback, evaluate all active automation mappings for a loop and apply each mapped value to its target plugin parameter. +- Playback should be additive/independent per mapping, not a single shared curve. +- If multiple automation mappings target the same plugin parameter, the system should either: + - apply them in deterministic order, or + - define a clear precedence rule such as “last wired mapping wins” or “multi-lane blend.” +- The initial implementation should favor deterministic, easy-to-reason-about behavior: evaluate mappings in insertion order and apply each to its target parameter. + +### 5-D: Rendering Multiple Automation Curves +- The display should support rendering multiple automation lanes for the same loop, each with its own texture-backed curve. +- One vertex shader uniform (Radius) and one fragment shader uniform (Color) are used to distinguish different parameters/lanes in the 3D visualization. +- The shader path should remain generic: one automation curve texture can drive one visual band/ribbon, and multiple bands can be drawn for multiple mapped parameters. + +--- + +## Phase 6: Holographic 3D Undulating Display + +### 6-A: Pipeline Shader Modifications +- Store shader pipeline configs [Jamma/resources/shaders/automation.vert](Jamma/resources/shaders/automation.vert) and [Jamma/resources/shaders/automation.frag](Jamma/resources/shaders/automation.frag): + - **Vertex Shader**: Use the automation lookup texture to sample the curve by the vertex’s loop position, then offset the geometry along the circular trajectory using the sampled height value. + - **Fragment Shader**: Apply a color gradient (circumferentially, based on uv coordinates, so color and alpha fade with loop time, brightest at current play position). Also feature bright highlighted thick top edge and vertical play position. Should brighten when recording is active (frag uniform). +- The vertex shader should perform piecewise-linear interpolation across the sparse control points stored in the $N \times 2$ texture (first coord is time [0:1], second coord is automation height value), so the display updates live while recording without CPU resampling. + +### 6-B: VAO Setup inside `MidiModel` +- Update [JammaLib/src/graphics/MidiModel.h](JammaLib/src/graphics/MidiModel.h) and [JammaLib/src/graphics/MidiModel.cpp](JammaLib/src/graphics/MidiModel.cpp): + - Cache a reference pointer to `midi::MidiLoop` in `MidiModel`. + - Build a fixed mesh once for the automation display, with the geometry defined in a reusable VAO/VBO pair. UV's normalised to the loop length / full arc. The main mesh should be a circular curtain with a small vertical height (scaled per LoopTake height), and the vertices should be arranged in a triangle strip around the circumference. + - The glowing ring crown should be a separate circular line loop drawn above the curtain (same VBO/VAO, different shaders). + - The vertical showing playposition should be a thin line drawn on top of the curtain, staying facing the camera fixed (play position is always facing forward). + - A bright dot should also be drawn at the current play position on the curtain, with a small halo glow (distinct fragment shader, simple vertex shader - just set vertical position in draw3d as a translation matrix multipled with MVP and not use the automation lookup texture). + - Keep the meshes static across draws; the vertex shader should deform them using the current automation lookup texture and the per-vertex loop position (except for dot, which gets set by MVP transform). + - Use `GL_TRIANGLE_STRIP` for the undulating 3D curtain and `GL_LINE_LOOP` for the glowing ring crown. + - Re-upload the automation texture whenever the control-point set changes during recording so the display updates immediately, but do not regenerate the mesh on every `Draw3d` call. From 96fef05b56ff3555a93d507b7129d03f239bb72c Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Wed, 17 Jun 2026 02:07:06 +0100 Subject: [PATCH 05/27] Complete MIDI automation session 1 --- .../plan-midiAutomationRecording.prompt.md | 34 ++-- JammaLib/src/engine/Scene.cpp | 180 ++++++++++++++++++ JammaLib/src/engine/Scene.h | 14 +- JammaLib/src/engine/Station.cpp | 93 +++++++++ JammaLib/src/engine/Station.h | 44 +++++ JammaLib/src/midi/MidiLoop.cpp | 97 ++++++++++ JammaLib/src/midi/MidiLoop.h | 76 ++++++++ JammaLib/src/midi/MidiRouter.cpp | 52 ++++- JammaLib/src/midi/MidiRouter.h | 17 +- JammaLib/src/vst/IVstPlugin.h | 29 ++- JammaLib/src/vst/Vst.cpp | 8 + JammaLib/src/vst/Vst2Plugin.cpp | 34 +++- JammaLib/src/vst/Vst2Plugin.h | 4 + JammaLib/src/vst/Vst3Plugin.cpp | 12 ++ JammaLib/src/vst/Vst3Plugin.h | 6 + .../engine/StationMidiInstrument_Tests.cpp | 103 +++++++++- 16 files changed, 775 insertions(+), 28 deletions(-) diff --git a/.vscode/plan-midiAutomationRecording.prompt.md b/.vscode/plan-midiAutomationRecording.prompt.md index bcbfa18b..bc7b6020 100644 --- a/.vscode/plan-midiAutomationRecording.prompt.md +++ b/.vscode/plan-midiAutomationRecording.prompt.md @@ -92,14 +92,14 @@ graph TD ## Phase 1: Track Last Touched Parameter -### 1-A: Interface Extension `IVstPlugin` +### [DONE] 1-A: Interface Extension `IVstPlugin` - Modify `IVstPlugin` in [JammaLib/src/vst/IVstPlugin.h](JammaLib/src/vst/IVstPlugin.h) to expose parameter setters: ```cpp virtual void SetParameter(unsigned int index, float value) noexcept = 0; virtual float GetParameter(unsigned int index) const noexcept = 0; ``` -### 1-B: VST2 Plugin Implementation +### [DONE] 1-B: VST2 Plugin Implementation - Implement the methods in [JammaLib/src/vst/Vst2Plugin.h](JammaLib/src/vst/Vst2Plugin.h) and [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp): ```cpp void Vst2Plugin::SetParameter(unsigned int index, float value) noexcept { @@ -113,7 +113,7 @@ graph TD ``` - Implement empty stubs in [JammaLib/src/vst/Vst3Plugin.cpp](JammaLib/src/vst/Vst3Plugin.cpp) to satisfy the compiler interface checks. -### 1-C: Last Touched Registry +### [DONE] 1-C: Last Touched Registry - Add a thread-safe registry in `vst` namespace inside [JammaLib/src/vst/IVstPlugin.h](JammaLib/src/vst/IVstPlugin.h): ```cpp struct LastTouchedParameter { @@ -121,15 +121,15 @@ graph TD std::atomic ParameterIndex{0u}; std::atomic Value{0.0f}; }; - extern LastTouchedParameter g_lastTouchedParam; + extern LastTouchedParameter _lastTouchedParam; ``` - Update `HostCallback` in [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp)'s `audioMasterAutomate` case: ```cpp case audioMasterAutomate: if (self) { - g_lastTouchedParam.Plugin.store(self, std::memory_order_relaxed); - g_lastTouchedParam.ParameterIndex.store(index, std::memory_order_relaxed); - g_lastTouchedParam.Value.store(opt, std::memory_order_relaxed); + _lastTouchedParam.Plugin.store(self, std::memory_order_relaxed); + _lastTouchedParam.ParameterIndex.store(index, std::memory_order_relaxed); + _lastTouchedParam.Value.store(opt, std::memory_order_relaxed); } return 0; ``` @@ -138,7 +138,7 @@ graph TD ## Phase 2: In-Memory Automation Timeline inside `MidiLoop` -### 2-A: Automation Timeline Representation +### [DONE] 2-A: Automation Timeline Representation - Represent the automation as sparse control points in the loop timeline and upload them to the GPU as an $N \times 2$ lookup texture, where each texel stores: - `R`/`x`: the automation value - `G`/`y`: the fractional position through the loop where that value applies @@ -192,7 +192,7 @@ graph TD ``` - The CPU keeps each lane's sparse points in their native form; the GPU receives a lane's points as a compact 2-channel texture for display. Each lane uploads independently. -### 2-B: Read/Write Interpolation Implementation +### [DONE] 2-B: Read/Write Interpolation Implementation - Inside [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp): - Preserve sparse points exactly as they are recorded. - When a new point arrives, insert or update the point in the loop-local control-point storage. @@ -203,7 +203,7 @@ graph TD ## Phase 3: Keyboard Arming and MIDI Learn Hooks -### 3-A: Interactive State Flags +### [DONE] 3-A: Interactive State Flags - Add flags inside [JammaLib/src/midi/MidiRouter.h](JammaLib/src/midi/MidiRouter.h): ```cpp namespace midi { @@ -218,7 +218,7 @@ graph TD - `LearnedCC` and `LearnedChannel` are **written inside `MidiRouter::PumpMidi`** whenever a CC message arrives and `LearnMidiCCMode` is `true`. This is the CC capture step: the user moves a physical knob/slider and the system automatically captures the controller number and channel without any further key press. - `RecordTargetLoop` is a **raw observer pointer** — its lifetime is externally guaranteed by the `LoopTake` that owns it. It must be cleared to `nullptr` before any `MidiLoop` is destroyed (handled in `LoopTake` teardown). -### 3-B: Interactive Key Bindings +### [DONE] 3-B: Interactive Key Bindings - Modify `Scene::OnAction(KeyAction)` in [JammaLib/src/engine/Scene.cpp](JammaLib/src/engine/Scene.cpp#L446): - Key `L` — **Learn Mode** (on key-down): Toggle `LearnMidiCCMode`. When toggling **off**, also reset `LearnedCC` and `LearnedChannel` to `0xffu` so a stale capture cannot be accidentally wired on a future press. - Key `W` — **Wire Command** (on key-down): @@ -235,7 +235,7 @@ graph TD ## Phase 4: Audio Thread Playback Routing -### 4-A: Pre-baked Flat Dispatch List (eliminates nested weak_ptr::lock chains) +### [DONE] 4-A: Pre-baked Flat Dispatch List (eliminates nested weak_ptr::lock chains) The naïve approach of walking `state.LoopTakes → weak_ptr::lock → GetMidiLoops → GetAutomation` inside `_RunVstBlock` pays O(takes × midiLoops) in atomic refcount increments, shared_ptr pointer chasing, and per-entry atomic loads — every audio block, at audio-thread priority. This is unacceptable. @@ -262,7 +262,7 @@ std::uint8_t _automationDispatchBack = 0u; Add `_RebuildAutomationDispatch()` — called on the non-audio thread whenever automation is wired or the loop set changes. It walks all takes and MIDI loops, resolves all raw pointers, and atomically publishes the new front buffer. This is the only place that traverses weak_ptrs and shared_ptrs for automation purposes. -### 4-B: Per-loop `frac` from `blockStartSample` (not from the clock) +### [DONE] 4-B: Per-loop `frac` from `blockStartSample` (not from the clock) `_clock->FractionalPosition()` would use `SeedSourceLength()` as the denominator — correct only for the seed loop. Overdub loops may be harmonically related but still have independent lengths. Each dispatch entry stores `loopLengthSamps` pre-resolved at build time. Per-block fractional position: @@ -275,7 +275,7 @@ const double frac = (entry.loopLengthSamps > 0u) This is correct for all loop configurations and costs one `fmod` instead of an indirect clock method call. -### 4-C: Playback cursor — O(1) amortised interpolation (replaces O(N) scan) +### [DONE] 4-C: Playback cursor — O(1) amortised interpolation (replaces O(N) scan) `GetAutomationValueAtFrac` with up to 256 sparse control points costs up to 256 comparisons per active mapping per block if searching from index 0. Since playback is monotonically forward (wrapping only at loop boundaries), the bracket for the current `frac` advances by at most 1–2 control points per block in normal use. @@ -286,7 +286,7 @@ Store `cursorIdx` in each `AutomationDispatch`. Per block: Amortised cost across a full loop playback: O(total control points) — equivalent to a single pass, not one pass per block. -### 4-D: Delta-threshold gate — suppress redundant `SetParameter` calls +### [DONE] 4-D: Delta-threshold gate — suppress redundant `SetParameter` calls VST2 `setParameter` is an opcode dispatch that can trigger coefficient recalculation inside the plugin on every call. On a flat or slow-moving automation curve (the common case), the value barely changes between consecutive blocks. Gate the call: @@ -300,7 +300,9 @@ if (std::abs(val - entry.lastValue) > automationEpsilon) { On a steady-state loop after the first pass, this reduces `SetParameter` calls to zero — the dominant cost on a playing-but-not-moving automation lane. -### 4-E: Final audio-thread dispatch loop in `_RunVstBlock` +### [DONE] 4-E: Final audio-thread dispatch loop in `_RunVstBlock` + +> **Agent note:** Implemented as `Station::_RunAutomationDispatch(blockStartSample)`, called from `Station::WriteBlock` immediately after `_RunVstBlock`. It is deliberately *not* gated behind `vstActive`, so recorded parameter motion still drives a bypassed/idle chain. Buffer-index derivation, acquire/release fencing, per-loop `fmod`, cursor advance, and the delta gate (`AutomationEpsilon = 1/65536`) match the plan. Verified by `StationAutomation.*` GTests in `test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp`. Test hook: `Station::RunAutomationDispatchForTest`. With the above in place, the audio-thread path collapses to: diff --git a/JammaLib/src/engine/Scene.cpp b/JammaLib/src/engine/Scene.cpp index 1e07e7fb..bc33b43a 100644 --- a/JammaLib/src/engine/Scene.cpp +++ b/JammaLib/src/engine/Scene.cpp @@ -528,6 +528,13 @@ ActionResult Scene::OnAction(KeyAction action) _networkService->GetController()); } + // Ctrl+Shift+L/W/X/[/]/A - MIDI automation learn, wire, delete, lane cycle, record. + { + auto automationRes = _HandleAutomationKey(action); + if (automationRes.IsEaten) + return automationRes; + } + bool checkReset = false; auto result = ActionResult::NoAction(); @@ -593,6 +600,179 @@ ActionResult Scene::_HandleUndo() return { res }; } +std::pair, std::shared_ptr> Scene::_ResolveHoveredAutomationTarget() +{ + auto hoverPath = _selector->CurrentHover(); + if (hoverPath.empty()) + return { nullptr, nullptr }; + + const auto stationIndex = hoverPath[0]; + if (stationIndex >= _stations.size()) + return { nullptr, nullptr }; + + auto station = _stations[stationIndex]; + if (!station || station->IsRemote()) + return { nullptr, nullptr }; + + // Prefer the hovered LoopTake; otherwise fall back to the station's first take. + std::shared_ptr take; + if (auto hovered = _ChildFromPath(hoverPath)) + take = std::dynamic_pointer_cast(hovered); + if (!take) + { + const auto& takes = station->GetLoopTakes(); + if (!takes.empty()) + take = takes.front(); + } + if (!take) + return { nullptr, nullptr }; + + const auto& midiLoops = take->GetMidiLoops(); + if (midiLoops.empty() || !midiLoops.front()) + return { nullptr, nullptr }; + + return { station, midiLoops.front() }; +} + +ActionResult Scene::_HandleAutomationKey(actions::KeyAction action) +{ + // All automation commands require Ctrl+Shift to avoid clashing with the bare + // letter keys already bound to loop triggers. + const bool ctrlShift = (Action::MODIFIER_CTRL & action.Modifiers) + && (Action::MODIFIER_SHIFT & action.Modifiers); + const bool isDown = (actions::KeyAction::KEY_DOWN == action.KeyActionType); + const bool isUp = (actions::KeyAction::KEY_UP == action.KeyActionType); + + auto eaten = ActionResult::NoAction(); + eaten.IsEaten = true; + + // 'A' record: begin on Ctrl+Shift+A down, end on A up (regardless of modifier + // so releasing the key always resumes playback). Guarded so unrelated A-ups are + // only consumed when we actually started an automation recording. + if (65 == action.KeyChar) + { + if (isDown && ctrlShift && !_automationRecordKeyHeld) + { + auto [station, loop] = _ResolveHoveredAutomationTarget(); + if (!loop || !station) + return ActionResult::NoAction(); + + _automationRecordKeyHeld = true; + _automationRecordStation = station; + midi::RecordTargetLoop.store(loop.get(), std::memory_order_relaxed); + midi::AutomationRecordHeld.store(true, std::memory_order_relaxed); + std::cout << ">> Automation record armed (Ctrl+Shift+A) <<" << std::endl; + return eaten; + } + if (isUp && _automationRecordKeyHeld) + { + _automationRecordKeyHeld = false; + // Release order: clear the held flag, then publish the dispatch with a + // release fence so the audio thread sees all recorded points. + midi::AutomationRecordHeld.store(false, std::memory_order_relaxed); + midi::RecordTargetLoop.store(nullptr, std::memory_order_relaxed); + if (auto station = _automationRecordStation.lock()) + station->RebuildAutomationDispatch(); + _automationRecordStation.reset(); + std::cout << ">> Automation record released <<" << std::endl; + return eaten; + } + return ActionResult::NoAction(); + } + + // The remaining commands are edge-triggered on Ctrl+Shift + key-down. + if (!isDown || !ctrlShift) + return ActionResult::NoAction(); + + switch (action.KeyChar) + { + case 76: // 'L' - toggle learn mode + { + const bool wasOn = midi::LearnMidiCCMode.load(std::memory_order_relaxed); + const bool nowOn = !wasOn; + midi::LearnMidiCCMode.store(nowOn, std::memory_order_relaxed); + if (!nowOn) + { + // Toggling off clears any stale capture so it can't be wired later. + midi::LearnedCC.store(midi::LearnNothingCaptured, std::memory_order_relaxed); + midi::LearnedChannel.store(midi::LearnNothingCaptured, std::memory_order_relaxed); + } + std::cout << ">> MIDI learn mode " << (nowOn ? "ON" : "OFF") << " <<" << std::endl; + return eaten; + } + case 87: // 'W' - wire the captured CC + last-touched parameter to the selected lane + { + const auto learnedCC = midi::LearnedCC.load(std::memory_order_relaxed); + auto* plugin = vst::_lastTouchedParam.Plugin.load(std::memory_order_relaxed); + if (learnedCC == midi::LearnNothingCaptured || !plugin) + { + std::cout << "Automation wire ignored: no captured CC or touched parameter" << std::endl; + return ActionResult::NoAction(); + } + + auto [station, loop] = _ResolveHoveredAutomationTarget(); + if (!loop || !station) + return ActionResult::NoAction(); + + const auto channel = midi::LearnedChannel.load(std::memory_order_relaxed); + const auto paramIdx = vst::_lastTouchedParam.ParameterIndex.load(std::memory_order_relaxed); + const auto laneIdx = midi::SelectedLaneIndex.load(std::memory_order_relaxed); + + auto& lane = loop->GetLane(laneIdx); + lane.Mapping.TargetPlugin = plugin; + lane.Mapping.TargetParameterIndex = paramIdx; + lane.Mapping.MatchKey.store( + midi::AutomationMapping::MakeMatchKey(channel & 0x0Fu, learnedCC), + std::memory_order_relaxed); + + // Exit learn mode and clear the capture so the next wire is deliberate. + midi::LearnMidiCCMode.store(false, std::memory_order_relaxed); + midi::LearnedCC.store(midi::LearnNothingCaptured, std::memory_order_relaxed); + midi::LearnedChannel.store(midi::LearnNothingCaptured, std::memory_order_relaxed); + + station->RebuildAutomationDispatch(); + std::cout << ">> Automation wired: lane " << static_cast(laneIdx) + << " <- CC " << static_cast(learnedCC) << " ch " << static_cast(channel) + << " -> param " << paramIdx << " <<" << std::endl; + return eaten; + } + case 88: // 'X' - delete the selected lane on the hovered loop + { + auto [station, loop] = _ResolveHoveredAutomationTarget(); + if (!loop || !station) + return ActionResult::NoAction(); + + const auto laneIdx = midi::SelectedLaneIndex.load(std::memory_order_relaxed); + loop->ClearAutomationLane(laneIdx); + station->RebuildAutomationDispatch(); + std::cout << ">> Automation lane " << static_cast(laneIdx) << " cleared <<" << std::endl; + return eaten; + } + case 91: // '[' - select previous lane + { + auto laneIdx = midi::SelectedLaneIndex.load(std::memory_order_relaxed); + laneIdx = (laneIdx == 0u) + ? static_cast(midi::MidiLoop::MaxAutomationLanes - 1u) + : static_cast(laneIdx - 1u); + midi::SelectedLaneIndex.store(laneIdx, std::memory_order_relaxed); + std::cout << ">> Selected automation lane " << static_cast(laneIdx) << " <<" << std::endl; + return eaten; + } + case 93: // ']' - select next lane + { + auto laneIdx = midi::SelectedLaneIndex.load(std::memory_order_relaxed); + laneIdx = static_cast((laneIdx + 1u) % midi::MidiLoop::MaxAutomationLanes); + midi::SelectedLaneIndex.store(laneIdx, std::memory_order_relaxed); + std::cout << ">> Selected automation lane " << static_cast(laneIdx) << " <<" << std::endl; + return eaten; + } + default: + break; + } + + return ActionResult::NoAction(); +} + ActionResult Scene::OnAction(GuiAction action) { switch (action.ElementType) diff --git a/JammaLib/src/engine/Scene.h b/JammaLib/src/engine/Scene.h index c6b5b18f..f0b2b93b 100644 --- a/JammaLib/src/engine/Scene.h +++ b/JammaLib/src/engine/Scene.h @@ -8,6 +8,7 @@ #include #include #include +#include #include "../resources/ResourceLib.h" #include "../actions/JobAction.h" #include "../audio/AudioDevice.h" @@ -265,8 +266,13 @@ namespace engine void _AddStation(std::shared_ptr station); void _HandleReclockArm(); actions::ActionResult _HandleUndo(); - void _SetQuantisation(unsigned int quantiseSamps, utils::Timer::QuantisationType quantisation); - void _SetMidiQuantisationGrain(unsigned int grainSamps, const char* source) + // MIDI automation interaction (Ctrl+Shift+L/W/X/[/]/A). Returns an eaten + // result when the key was handled as an automation command, NoAction otherwise. + actions::ActionResult _HandleAutomationKey(actions::KeyAction action); + // Resolve the hovered LoopTake's primary MIDI loop and its owning station. + // Returns {nullptr, nullptr} when the hover does not target a MIDI loop. + std::pair, std::shared_ptr> _ResolveHoveredAutomationTarget(); + void _SetQuantisation(unsigned int quantiseSamps, utils::Timer::QuantisationType quantisation); void _SetMidiQuantisationGrain(unsigned int grainSamps, const char* source) { _quantisation.SetMidiGrain(grainSamps, source, _stations); } @@ -324,6 +330,10 @@ namespace engine std::atomic_bool _isSceneQuitting; std::atomic_bool _isSceneReset; bool _isSceneDragged; + // True while a Ctrl+Shift+A automation recording is in progress (key held). + bool _automationRecordKeyHeld = false; + // Station owning the loop currently being automation-recorded; rebuilt on release. + std::weak_ptr _automationRecordStation; utils::Position2d _initTouchDownPosition; utils::Position3d _initTouchCamPosition; glm::mat4 _viewProj; diff --git a/JammaLib/src/engine/Station.cpp b/JammaLib/src/engine/Station.cpp index 09271afa..a093b892 100644 --- a/JammaLib/src/engine/Station.cpp +++ b/JammaLib/src/engine/Station.cpp @@ -1,7 +1,9 @@ #include "Station.h" #include +#include #include #include +#include "../midi/MidiRouter.h" using namespace engine; using namespace timing; @@ -355,6 +357,11 @@ void Station::WriteBlock(const std::shared_ptr dest, _RunVstBlock(chain.get(), routes, *state, vstActive, static_cast(channelCount), sampsToRead, blockStartSample); + // Drive any wired parameter automation. Runs independently of vstActive so a + // bypassed/idle chain still receives recorded parameter motion. Flat loop over + // the pre-baked dispatch list — no weak_ptr locks, no shared_ptr chasing. + _RunAutomationDispatch(blockStartSample); + if (channelCount == 0u) { _masterMixer->UpdateVu(0.0f, sampsToRead); @@ -450,6 +457,92 @@ void Station::_RunVstBlock(vst::VstChain* chain, chain->ProcessBlockMulti(state.VstBlockPtrs.data(), static_cast(channelCount), sampsToRead); } +void Station::RebuildAutomationDispatch() +{ + // Non-audio thread only. Walk every take and MIDI loop, resolve all raw + // pointers and lane metadata into a compact flat list, then publish it. + const std::uint8_t back = _automationDispatchBack; + auto* buf = _automationDispatchBuf[back]; + std::uint8_t count = 0u; + + const auto& takes = GetLoopTakes(); + for (const auto& take : takes) + { + if (!take) + continue; + + for (const auto& midiLoop : take->GetMidiLoops()) + { + if (!midiLoop) + continue; + + for (std::size_t laneIdx = 0u; laneIdx < midi::MidiLoop::MaxAutomationLanes; ++laneIdx) + { + if (count >= MaxAutomationDispatches) + break; + + const auto& lane = midiLoop->GetLane(laneIdx); + if (!lane.Mapping.IsActive() || !lane.Mapping.TargetPlugin) + continue; + + auto& entry = buf[count]; + entry.plugin = lane.Mapping.TargetPlugin; + entry.paramIdx = lane.Mapping.TargetParameterIndex; + entry.loop = midiLoop.get(); + entry.laneIdx = static_cast(laneIdx); + entry.loopLengthSamps = midiLoop->LoopLengthSamps(); + entry.cursorIdx = 0u; + entry.lastValue = -2.0f; // force first write after a rebuild + ++count; + } + } + } + + _automationDispatchCount[back] = count; + // Release pairs with the audio thread's acquire load: makes all MIDI-thread + // writes to lane Points visible before the new list is consumed. + _automationDispatch.store(buf, std::memory_order_release); + _automationDispatchBack ^= 1u; +} + +void Station::_RunAutomationDispatch(std::uint32_t blockStartSample) noexcept +{ + auto* dispatches = _automationDispatch.load(std::memory_order_acquire); + if (!dispatches) + return; + + // acquire above pairs with the release store in RebuildAutomationDispatch. + const bool recordHeld = midi::AutomationRecordHeld.load(std::memory_order_acquire); + const auto* recordTarget = midi::RecordTargetLoop.load(std::memory_order_relaxed); + const std::uint8_t frontIdx = (dispatches == _automationDispatchBuf[0]) ? 0u : 1u; + const auto count = _automationDispatchCount[frontIdx]; + + for (std::uint8_t i = 0u; i < count; ++i) + { + auto& entry = dispatches[i]; + if (!entry.plugin || !entry.loop) + continue; + + // Suppress playback only for lanes on the loop actively being recorded; + // other loops' lanes keep playing back unaffected. + if (recordHeld && entry.loop == recordTarget) + continue; + + const double frac = (entry.loopLengthSamps > 0u) + ? std::fmod(static_cast(blockStartSample), static_cast(entry.loopLengthSamps)) + / static_cast(entry.loopLengthSamps) + : 0.0; + + const float val = entry.loop->GetAutomationValueAtCursor(entry.laneIdx, frac, entry.cursorIdx); + + if (std::abs(val - entry.lastValue) > AutomationEpsilon) + { + entry.plugin->SetParameter(entry.paramIdx, val); + entry.lastValue = val; + } + } +} + void Station::EndMultiPlay(unsigned int numSamps) { auto state = _AudioStateSnapshot(); diff --git a/JammaLib/src/engine/Station.h b/JammaLib/src/engine/Station.h index f699171c..93f94003 100644 --- a/JammaLib/src/engine/Station.h +++ b/JammaLib/src/engine/Station.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -180,6 +181,19 @@ namespace engine // Non-RT accessor to retrieve a loaded plugin instance (or nullptr). std::shared_ptr GetVstPlugin(size_t index) const; + // Rebuild the flat parameter-automation dispatch list from the current + // loop set and lane wirings, then publish it atomically for the audio + // thread. Call on the non-audio (UI/action) thread whenever automation + // wiring changes or a recording is released. + void RebuildAutomationDispatch(); + + // Test hook: run one automation dispatch block in isolation (drives + // SetParameter on wired plugins). Non-RT; mirrors the audio-thread path. + void RunAutomationDispatchForTest(std::uint32_t blockStartSample) noexcept + { + _RunAutomationDispatch(blockStartSample); + } + // Called on the job thread to actually perform the load / unload. virtual actions::ActionResult OnAction(actions::JobAction action) override; @@ -251,6 +265,28 @@ namespace engine // Must be called from the action thread; NoteOffs are delivered via EnqueueLiveMidiEvent. void _DitchLoopTake(std::shared_ptr& take) noexcept; + // --- Parameter automation dispatch --- + + // One pre-resolved automation mapping ready for the audio thread. All + // pointer chasing and lane metadata are baked in by RebuildAutomationDispatch + // so the audio path is a flat loop over a hot cache line. + struct AutomationDispatch + { + vst::IVstPlugin* plugin = nullptr; // raw observer — lifetime owned by VstChain + unsigned int paramIdx = 0u; + midi::MidiLoop* loop = nullptr; // raw observer — lifetime owned by LoopTake + std::uint8_t laneIdx = 0u; // which lane within loop to read + std::uint32_t loopLengthSamps = 0u; // pre-resolved; avoids per-block takes lock + std::uint16_t cursorIdx = 0u; // playback cursor for amortised O(1) interpolation + float lastValue = -2.0f; // sentinel: force first write + }; + static constexpr std::size_t MaxAutomationDispatches = 64u; + static constexpr float AutomationEpsilon = 1.0f / 65536.0f; // below 16-bit param resolution + + // Run one automation dispatch block on the audio thread: advance each + // lane's cursor, interpolate, and SetParameter (delta-gated). Real-time safe. + void _RunAutomationDispatch(std::uint32_t blockStartSample) noexcept; + bool _flipTakeBuffer; bool _flipAudioBuffer; std::string _name; @@ -274,6 +310,14 @@ namespace engine std::vector> _backAudioBuffers; std::atomic> _audioState; + // Flat automation dispatch list, double-buffered and published with an + // atomic-swap release store (audio thread reads with acquire). Built only on + // the non-audio thread in RebuildAutomationDispatch. + std::atomic _automationDispatch{ nullptr }; + AutomationDispatch _automationDispatchBuf[2][MaxAutomationDispatches]{}; + std::uint8_t _automationDispatchCount[2]{}; + std::uint8_t _automationDispatchBack = 0u; + // VST insert chain applied after all LoopTakes are mixed down, // just before each channel is sent to the output AudioMixer. // Published atomically for lock-free audio-thread reads. diff --git a/JammaLib/src/midi/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index e5e0e3ac..877d103b 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -357,3 +357,100 @@ void MidiLoop::PublishQuantisedEvents() _retainedQuantisedEvents.push_back(std::move(quantisedEvents)); _quantisedEvents.store(snapshot, std::memory_order_release); } + +void MidiLoop::SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float value) noexcept +{ + if (laneIdx >= MaxAutomationLanes) + return; + + auto& lane = _lanes[laneIdx]; + auto fracF = static_cast(frac); + if (fracF < 0.0f) + fracF = 0.0f; + else if (fracF > 1.0f) + fracF = 1.0f; + + // Merge points that land on (approximately) the same fractional position so a + // stream of CC values recorded close together doesn't exhaust the buffer. + constexpr float fracEpsilon = 1.0f / 2048.0f; + + auto& points = lane.Points; + auto& count = lane.PointCount; + + // Find the insertion index (points are kept sorted by frac ascending). + std::size_t insertAt = 0u; + while (insertAt < count && points[insertAt].first < fracF) + ++insertAt; + + // Update in place if a neighbouring point shares this fractional position. + if (insertAt < count && (points[insertAt].first - fracF) <= fracEpsilon) + { + points[insertAt].second = value; + return; + } + if (insertAt > 0u && (fracF - points[insertAt - 1u].first) <= fracEpsilon) + { + points[insertAt - 1u].second = value; + return; + } + + if (count >= AutomationLane::MaxPoints) + return; // Buffer full: drop newest. + + // Shift tail right to make room, then insert. Fixed-capacity, no allocation. + for (std::size_t i = count; i > insertAt; --i) + points[i] = points[i - 1u]; + points[insertAt] = std::make_pair(fracF, value); + ++count; +} + +float MidiLoop::GetAutomationValueAtCursor(std::size_t laneIdx, double frac, std::uint16_t& cursorIdx) const noexcept +{ + if (laneIdx >= MaxAutomationLanes) + return 0.0f; + + const auto& lane = _lanes[laneIdx]; + const auto count = lane.PointCount; + if (0u == count) + return 0.0f; + + const auto& points = lane.Points; + const auto fracF = static_cast(frac); + + // Clamp cursor into range and reset on loop wrap (frac stepped backward). + if (cursorIdx >= count) + cursorIdx = 0u; + if (fracF < points[cursorIdx].first) + cursorIdx = 0u; + + // Advance forward while the next point still starts at or before frac. + while ((cursorIdx + 1u) < count && points[cursorIdx + 1u].first <= fracF) + ++cursorIdx; + + // Before the first point: hold the first value. At/after the last: hold last. + if (fracF <= points[0].first) + return points[0].second; + if ((cursorIdx + 1u) >= count) + return points[count - 1u].second; + + const auto& lo = points[cursorIdx]; + const auto& hi = points[cursorIdx + 1u]; + const auto span = hi.first - lo.first; + if (span <= 0.0f) + return hi.second; + + const auto t = (fracF - lo.first) / span; + return lo.second + t * (hi.second - lo.second); +} + +void MidiLoop::ClearAutomationLane(std::size_t laneIdx) noexcept +{ + if (laneIdx >= MaxAutomationLanes) + return; + + auto& lane = _lanes[laneIdx]; + lane.Mapping.MatchKey.store(AutomationMapping::kInactive, std::memory_order_relaxed); + lane.Mapping.TargetPlugin = nullptr; + lane.Mapping.TargetParameterIndex = 0u; + lane.PointCount = 0u; +} diff --git a/JammaLib/src/midi/MidiLoop.h b/JammaLib/src/midi/MidiLoop.h index af21b279..3bf0aec5 100644 --- a/JammaLib/src/midi/MidiLoop.h +++ b/JammaLib/src/midi/MidiLoop.h @@ -6,12 +6,18 @@ #include #include #include +#include #include #include "../graphics/MidiModel.h" #include "MidiEvent.h" #include "MidiQuantisation.h" +namespace vst +{ + class IVstPlugin; +} + namespace midi { using namespace graphics; @@ -40,6 +46,53 @@ namespace midi Playing }; + // Metadata describing how a CC controller is wired to a hosted plugin + // parameter for one automation lane. + struct AutomationMapping + { + // Active, Channel, and CC must be read atomically together on the MIDI + // thread (CC matching) while written together on the UI thread (wire key). + // Pack all three into one uint32_t so a reader always sees a consistent + // triple. Encoding: bit 16 = Active, bits [15:8] = Channel, bits [7:0] = CC. + std::atomic MatchKey{ 0u }; + + // Written and read on the non-audio thread only (_RebuildAutomationDispatch + // and the wire/delete key handlers). No atomic needed. + vst::IVstPlugin* TargetPlugin{ nullptr }; + unsigned int TargetParameterIndex{ 0u }; + + static constexpr std::uint32_t kInactive = 0u; + + static constexpr std::uint32_t MakeMatchKey(std::uint8_t ch, std::uint8_t cc) noexcept + { + return (1u << 16) | (static_cast(ch) << 8) | static_cast(cc); + } + + bool IsActive() const noexcept + { + return ((MatchKey.load(std::memory_order_relaxed) >> 16) & 1u) != 0u; + } + std::uint8_t GetChannel() const noexcept + { + return static_cast(MatchKey.load(std::memory_order_relaxed) >> 8); + } + std::uint8_t GetCC() const noexcept + { + return static_cast(MatchKey.load(std::memory_order_relaxed)); + } + }; + + // One self-contained automation lane: its CC->parameter mapping plus its own + // sparse control-point buffer recorded along the loop timeline. + struct AutomationLane + { + static constexpr std::size_t MaxPoints = 256u; + + AutomationMapping Mapping; + std::array, MaxPoints> Points{}; // (frac, value) + std::size_t PointCount = 0u; + }; + // In-memory MIDI loop. Records sample-offset-stamped events relative to the // start of the loop, then plays them back through an IMidiSink with stable // sample-relative timing across arbitrary block boundaries. @@ -113,6 +166,28 @@ namespace midi bool QueueModelUpdateFromEvents(std::uint32_t displayLengthSamps = 0u, bool force = false); static constexpr std::size_t Capacity() noexcept { return DefaultCapacity; } + // --- Parameter automation lanes --- + static constexpr std::size_t MaxAutomationLanes = 8u; + + AutomationLane& GetLane(std::size_t idx) noexcept { return _lanes[idx]; } + const AutomationLane& GetLane(std::size_t idx) const noexcept { return _lanes[idx]; } + + // Write a control point for lane laneIdx at fractional loop position frac + // (0..1). If a point already exists at (approximately) frac the value is + // updated in place; otherwise the point is inserted in frac order. Real-time + // safe: fixed-capacity storage, no allocation. Points beyond capacity are + // dropped (drop-newest). Called on the MIDI thread during recording. + void SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float value) noexcept; + + // Cursor-advancing read on lane laneIdx: advances cursorIdx forward to the + // correct bracket for frac, returns the piecewise-linearly interpolated + // value. Resets the cursor on loop wrap (detected when frac steps backward). + // Amortised O(1) per block. Returns 0 when the lane has no points. + float GetAutomationValueAtCursor(std::size_t laneIdx, double frac, std::uint16_t& cursorIdx) const noexcept; + + // Clear a lane's mapping and control points (non-audio thread; delete key). + void ClearAutomationLane(std::size_t laneIdx) noexcept; + // Non-destructive start-time quantisation. Non-RT publication builds immutable // event buffers and publishes a raw pointer for audio-thread readers. Retained // buffers are not overwritten or freed until this MidiLoop is destroyed, so @@ -148,5 +223,6 @@ namespace midi std::bitset _held; std::shared_ptr _model; MidiQuantisationSettings _quantisation; + std::array _lanes{}; }; } diff --git a/JammaLib/src/midi/MidiRouter.cpp b/JammaLib/src/midi/MidiRouter.cpp index dd6b0a0e..c124db7c 100644 --- a/JammaLib/src/midi/MidiRouter.cpp +++ b/JammaLib/src/midi/MidiRouter.cpp @@ -7,9 +7,21 @@ #include "../engine/Trigger.h" #include "../io/UserConfig.h" #include "MidiTimestampMapper.h" - +#include "MidiLoop.h" using namespace midi; +namespace midi +{ + // Definitions of the interactive automation arming/learn state declared in + // MidiRouter.h. + std::atomic LearnMidiCCMode{ false }; + std::atomic LearnedCC{ LearnNothingCaptured }; + std::atomic LearnedChannel{ LearnNothingCaptured }; + std::atomic AutomationRecordHeld{ false }; + std::atomic SelectedLaneIndex{ 0u }; + std::atomic RecordTargetLoop{ nullptr }; +} + void MidiRouter::InitMidi(const io::UserConfig& cfg, const base::LoggingConfig& loggingConfig, std::atomic& audioSampleCounter, @@ -330,6 +342,44 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vectorLoopLengthSamps(); + if (loopLen > 0u) + { + const auto matchKey = AutomationMapping::MakeMatchKey(channel, cc); + const double frac = + static_cast(globalSampleNow % loopLen) / static_cast(loopLen); + const float value = static_cast(ingress.data2) / 127.0f; + for (std::size_t laneIdx = 0u; laneIdx < MidiLoop::MaxAutomationLanes; ++laneIdx) + { + auto& lane = loop->GetLane(laneIdx); + if (lane.Mapping.MatchKey.load(std::memory_order_relaxed) == matchKey) + loop->SetAutomationValueAtFrac(laneIdx, frac, value); + } + } + } + } + } + if ((msgType != midi::MidiEvent::NoteOn) && (msgType != midi::MidiEvent::NoteOff)) continue; diff --git a/JammaLib/src/midi/MidiRouter.h b/JammaLib/src/midi/MidiRouter.h index eb538d65..7cab1611 100644 --- a/JammaLib/src/midi/MidiRouter.h +++ b/JammaLib/src/midi/MidiRouter.h @@ -27,9 +27,22 @@ namespace engine namespace midi { + class MidiLoop; + + // Interactive automation arming/learn state, shared between the UI/action + // thread (Scene key handlers) and the MIDI thread (MidiRouter::PumpMidi). + // All fields are independently atomic; consumers tolerate brief torn reads. + extern std::atomic LearnMidiCCMode; + extern std::atomic LearnedCC; // 0xffu = nothing captured yet + extern std::atomic LearnedChannel; // 0xffu = nothing captured yet + extern std::atomic AutomationRecordHeld; // true while the record key is held + extern std::atomic SelectedLaneIndex; // lane slot W/A/X target (0..MaxAutomationLanes-1) + extern std::atomic RecordTargetLoop; // loop being recorded; nullptr = all lanes play back + + static constexpr std::uint8_t LearnNothingCaptured = 0xffu; + class MidiRouter - { - public: + { public: struct TriggerDispatchSummary { bool Activated = false; diff --git a/JammaLib/src/vst/IVstPlugin.h b/JammaLib/src/vst/IVstPlugin.h index 75b4fd75..43c30f9f 100644 --- a/JammaLib/src/vst/IVstPlugin.h +++ b/JammaLib/src/vst/IVstPlugin.h @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include "../midi/MidiEvent.h" #include "../utils/CommonTypes.h" @@ -64,6 +66,12 @@ namespace vst // Process numSamples of an exact-match multichannel bus in-place. Real-time safe. virtual void ProcessBlockMulti(float* const* channelBufs, int32_t numChannels, int32_t numSamples) noexcept = 0; + // Set / get a plugin parameter by index. Real-time safe: a single opcode + // dispatch into the hosted plugin, no allocation. Used by the audio-thread + // automation player to drive parameters from recorded control curves. + virtual void SetParameter(unsigned int index, float value) noexcept = 0; + virtual float GetParameter(unsigned int index) const noexcept = 0; + // Called once per block before ProcessBlock to supply host transport/tempo // context. Real-time safe. Default is a no-op (e.g. VST3 uses its own // mechanism). @@ -113,12 +121,27 @@ namespace vst virtual void SetState(const std::vector& /*blob*/) {} }; + // Records the most recently host-automated plugin parameter so the UI thread + // can wire it to a MIDI automation lane ("MIDI learn" target half). + // + // Threading: written on the audio/UI thread inside Vst2Plugin's + // audioMasterAutomate host callback when the user touches a parameter in a + // plugin editor; read on the non-audio (UI/action) thread when the user + // presses the wire key. Each field is independently atomic; readers must + // tolerate a brief torn triple, which is acceptable because wiring only + // happens after the user deliberately stops touching the control. + struct LastTouchedParameter + { + std::atomic Plugin{ nullptr }; + std::atomic ParameterIndex{ 0u }; + std::atomic Value{ 0.0f }; + }; + extern LastTouchedParameter _lastTouchedParam; + // Factory: creates the correct plugin type based on file extension. // Extension ".dll" -> Vst2Plugin (VST2) // Any other extension (e.g. ".vst3") -> Vst3Plugin (VST3) - std::shared_ptr MakePluginForPath(const std::wstring& path); - - // Queue a plugin for destruction on the UI thread. + std::shared_ptr MakePluginForPath(const std::wstring& path); // Queue a plugin for destruction on the UI thread. // VST3 plugins must be destroyed on the UI (message-pump) thread to avoid // crashing the host. Vst2Plugin may also be queued here for uniformity. // Safe to call from any thread. diff --git a/JammaLib/src/vst/Vst.cpp b/JammaLib/src/vst/Vst.cpp index 61398c39..9d505f21 100644 --- a/JammaLib/src/vst/Vst.cpp +++ b/JammaLib/src/vst/Vst.cpp @@ -1,2 +1,10 @@ // Legacy stub — no implementation needed; all VST3 logic is in Vst3Plugin.cpp and VstChain.cpp. +#include "IVstPlugin.h" + +namespace vst +{ + // Definition of the global last-touched parameter registry declared in IVstPlugin.h. + LastTouchedParameter _lastTouchedParam; +} + diff --git a/JammaLib/src/vst/Vst2Plugin.cpp b/JammaLib/src/vst/Vst2Plugin.cpp index b69213c9..ac371ffc 100644 --- a/JammaLib/src/vst/Vst2Plugin.cpp +++ b/JammaLib/src/vst/Vst2Plugin.cpp @@ -372,6 +372,27 @@ void Vst2Plugin::ProcessBlockMulti(float* const* channelBufs, int32_t numChannel #endif } +void Vst2Plugin::SetParameter(unsigned int index, float value) noexcept +{ +#ifdef JAMMA_VST2_ENABLED + if (_effect && _effect->setParameter) + _effect->setParameter(_effect, static_cast(index), value); +#else + (void)index; (void)value; +#endif +} + +float Vst2Plugin::GetParameter(unsigned int index) const noexcept +{ +#ifdef JAMMA_VST2_ENABLED + if (_effect && _effect->getParameter) + return _effect->getParameter(_effect, static_cast(index)); +#else + (void)index; +#endif + return 0.0f; +} + void Vst2Plugin::BeginMidiBlock(std::uint32_t blockStartSample, std::uint32_t numSamples) noexcept { @@ -488,9 +509,9 @@ void Vst2Plugin::IdleEditor() noexcept #ifdef JAMMA_VST2_ENABLED VstIntPtr __cdecl Vst2Plugin::HostCallback(AEffect* effect, - VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float /*opt*/) + VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt) { - (void)index; (void)value; + (void)value; auto* self = (effect && effect->user) ? static_cast(effect->user) : nullptr; @@ -542,7 +563,14 @@ VstIntPtr __cdecl Vst2Plugin::HostCallback(AEffect* effect, } return 0; case audioMasterAutomate: - // Parameter automation notification — no action needed for a basic host. + // Parameter automation notification: record the most recently touched + // parameter so the UI thread can wire it to a MIDI automation lane. + if (self) + { + _lastTouchedParam.Plugin.store(self, std::memory_order_relaxed); + _lastTouchedParam.ParameterIndex.store(static_cast(index), std::memory_order_relaxed); + _lastTouchedParam.Value.store(opt, std::memory_order_relaxed); + } return 0; case audioMasterIdle: // Called by some older plugins requesting idle processing. diff --git a/JammaLib/src/vst/Vst2Plugin.h b/JammaLib/src/vst/Vst2Plugin.h index 51eec973..0776472d 100644 --- a/JammaLib/src/vst/Vst2Plugin.h +++ b/JammaLib/src/vst/Vst2Plugin.h @@ -69,6 +69,10 @@ namespace vst // Process numSamples of an exact-match multichannel bus in-place. void ProcessBlockMulti(float* const* channelBufs, int32_t numChannels, int32_t numSamples) noexcept override; + // Set / get a hosted parameter by index. Real-time safe (single opcode dispatch). + void SetParameter(unsigned int index, float value) noexcept override; + float GetParameter(unsigned int index) const noexcept override; + void BeginMidiBlock(std::uint32_t blockStartSample, std::uint32_t numSamples) noexcept override; void SendMidiEvent(const midi::MidiEvent& event, diff --git a/JammaLib/src/vst/Vst3Plugin.cpp b/JammaLib/src/vst/Vst3Plugin.cpp index e4aaea59..456d9089 100644 --- a/JammaLib/src/vst/Vst3Plugin.cpp +++ b/JammaLib/src/vst/Vst3Plugin.cpp @@ -1049,6 +1049,18 @@ void Vst3Plugin::ProcessBlockMulti(float* const* channelBufs, int32_t numChannel #endif } +void Vst3Plugin::SetParameter(unsigned int index, float value) noexcept +{ + // VST3 parameter automation is not yet routed through this host. + (void)index; (void)value; +} + +float Vst3Plugin::GetParameter(unsigned int index) const noexcept +{ + (void)index; + return 0.0f; +} + void Vst3Plugin::BeginMidiBlock(std::uint32_t blockStartSample, std::uint32_t numSamples) noexcept { diff --git a/JammaLib/src/vst/Vst3Plugin.h b/JammaLib/src/vst/Vst3Plugin.h index 710bbde3..b89b9334 100644 --- a/JammaLib/src/vst/Vst3Plugin.h +++ b/JammaLib/src/vst/Vst3Plugin.h @@ -79,6 +79,12 @@ namespace vst // channelBufs must contain numChannels writable channel buffers. void ProcessBlockMulti(float* const* channelBufs, int32_t numChannels, int32_t numSamples) noexcept override; + // Set / get a hosted parameter by index. VST3 parameter automation is not + // wired through this host yet, so these are no-op stubs that satisfy the + // IVstPlugin interface (GetParameter returns 0). + void SetParameter(unsigned int index, float value) noexcept override; + float GetParameter(unsigned int index) const noexcept override; + void BeginMidiBlock(std::uint32_t blockStartSample, std::uint32_t numSamples) noexcept override; void SendMidiEvent(const midi::MidiEvent& event, diff --git a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp index 6df0b267..46f0fbf2 100644 --- a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp +++ b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp @@ -6,6 +6,8 @@ #include "actions/TriggerAction.h" #include "base/AudioSink.h" #include "engine/Station.h" +#include "midi/MidiLoop.h" +#include "midi/MidiRouter.h" using actions::JobAction; using actions::TriggerAction; @@ -52,6 +54,19 @@ namespace RealtimeFlags.push_back(isRealtime); } + void SetParameter(unsigned int index, float value) noexcept override + { + ParamSetCalls++; + LastParamIndex = index; + LastParamValue = value; + } + + float GetParameter(unsigned int index) const noexcept override + { + (void)index; + return LastParamValue; + } + bool OpenEditor(HWND) override { return false; } void CloseEditor() override {} utils::Size2d GetEditorSize() const noexcept override { return { 0, 0 }; } @@ -64,6 +79,9 @@ namespace std::uint32_t BlockSamples = 0u; unsigned int BeginCalls = 0u; unsigned int ProcessCalls = 0u; + unsigned int ParamSetCalls = 0u; + unsigned int LastParamIndex = 0u; + float LastParamValue = 0.0f; std::vector Events; std::vector RealtimeFlags; @@ -515,4 +533,87 @@ TEST(StationMidiInstrument, PunchBoundariesEmitLiveMidiTransitionsForSourceAndLi EXPECT_EQ(100u, plugin->Events[1].data2); EXPECT_TRUE(plugin->RealtimeFlags[0]); EXPECT_TRUE(plugin->RealtimeFlags[1]); -} \ No newline at end of file +} + +TEST(StationAutomation, WiredLaneDrivesPluginSetParameterDuringPlayback) +{ + auto station = MakeStation("station-automation"); + auto plugin = AddPlugin(station, L"fake-automation.dll"); + auto take = MakeMidiTake("automation-take"); + station->AddTake(take); + station->CommitChanges(); + + // Establish a recorded MIDI loop with a known length so frac mapping is defined. + take->Record({}, station->Name(), { 0u }, { "Keys" }); + take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); + take->Play(0u, 128u, 0u); + + ASSERT_EQ(1u, take->GetMidiLoops().size()); + auto midiLoop = take->GetMidiLoops()[0]; + ASSERT_NE(nullptr, midiLoop); + ASSERT_GT(midiLoop->LoopLengthSamps(), 0u); + + // Record two control points: 0.25 at frac 0, 0.75 at frac ~0.5. + const std::size_t laneIdx = 0u; + midiLoop->SetAutomationValueAtFrac(laneIdx, 0.0, 0.25f); + midiLoop->SetAutomationValueAtFrac(laneIdx, 0.5, 0.75f); + + auto& lane = midiLoop->GetLane(laneIdx); + lane.Mapping.TargetPlugin = plugin.get(); + lane.Mapping.TargetParameterIndex = 3u; + lane.Mapping.MatchKey.store( + midi::AutomationMapping::MakeMatchKey(0u, 7u), + std::memory_order_relaxed); + + station->RebuildAutomationDispatch(); + + // At block-start sample 0 the value should be the first control point. + station->RunAutomationDispatchForTest(0u); + ASSERT_GE(plugin->ParamSetCalls, 1u); + EXPECT_EQ(3u, plugin->LastParamIndex); + EXPECT_NEAR(0.25f, plugin->LastParamValue, 1.0e-4f); + + // Half-way through the loop the interpolated value should approach 0.75. + const auto halfLen = midiLoop->LoopLengthSamps() / 2u; + station->RunAutomationDispatchForTest(halfLen); + EXPECT_NEAR(0.75f, plugin->LastParamValue, 1.0e-2f); +} + +TEST(StationAutomation, RecordingTargetLoopSuppressesPlaybackForThatLoop) +{ + auto station = MakeStation("station-automation-record"); + auto plugin = AddPlugin(station, L"fake-automation-record.dll"); + auto take = MakeMidiTake("automation-record-take"); + station->AddTake(take); + station->CommitChanges(); + + take->Record({}, station->Name(), { 0u }, { "Keys" }); + take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); + take->Play(0u, 128u, 0u); + + auto midiLoop = take->GetMidiLoops()[0]; + ASSERT_NE(nullptr, midiLoop); + midiLoop->SetAutomationValueAtFrac(0u, 0.0, 0.4f); + + auto& lane = midiLoop->GetLane(0u); + lane.Mapping.TargetPlugin = plugin.get(); + lane.Mapping.TargetParameterIndex = 1u; + lane.Mapping.MatchKey.store( + midi::AutomationMapping::MakeMatchKey(0u, 11u), + std::memory_order_relaxed); + station->RebuildAutomationDispatch(); + + // While the loop is the active record target, its lanes must not play back. + midi::AutomationRecordHeld.store(true, std::memory_order_relaxed); + midi::RecordTargetLoop.store(midiLoop.get(), std::memory_order_relaxed); + station->RunAutomationDispatchForTest(0u); + EXPECT_EQ(0u, plugin->ParamSetCalls); + + // Releasing record resumes playback. + midi::AutomationRecordHeld.store(false, std::memory_order_relaxed); + midi::RecordTargetLoop.store(nullptr, std::memory_order_relaxed); + station->RunAutomationDispatchForTest(0u); + EXPECT_GE(plugin->ParamSetCalls, 1u); + EXPECT_EQ(1u, plugin->LastParamIndex); + EXPECT_NEAR(0.4f, plugin->LastParamValue, 1.0e-4f); +} From 581524e22aab63f2afc00c7b057b559a105d16e0 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Wed, 17 Jun 2026 11:56:28 +0100 Subject: [PATCH 06/27] Move automation specific methods out of Scene, allow multiple stations to record automation --- JammaLib/src/engine/Scene.cpp | 180 +------------- JammaLib/src/engine/Scene.h | 10 - JammaLib/src/engine/Station.cpp | 10 +- JammaLib/src/io/IoInputSubsystem.cpp | 8 + JammaLib/src/io/IoInputSubsystem.h | 5 + JammaLib/src/midi/MidiRouter.cpp | 229 ++++++++++++++++-- JammaLib/src/midi/MidiRouter.h | 35 ++- .../engine/StationMidiInstrument_Tests.cpp | 8 +- 8 files changed, 254 insertions(+), 231 deletions(-) diff --git a/JammaLib/src/engine/Scene.cpp b/JammaLib/src/engine/Scene.cpp index bc33b43a..edd593f9 100644 --- a/JammaLib/src/engine/Scene.cpp +++ b/JammaLib/src/engine/Scene.cpp @@ -530,7 +530,12 @@ ActionResult Scene::OnAction(KeyAction action) // Ctrl+Shift+L/W/X/[/]/A - MIDI automation learn, wire, delete, lane cycle, record. { - auto automationRes = _HandleAutomationKey(action); + auto hovered = _ChildFromPath(_selector->CurrentHover()); + auto hoveredTake = std::dynamic_pointer_cast(hovered); + auto automationRes = _inputSubsystem->HandleAutomationKey(action, + _stations, + _selector->CurrentHover(), + hoveredTake); if (automationRes.IsEaten) return automationRes; } @@ -600,179 +605,6 @@ ActionResult Scene::_HandleUndo() return { res }; } -std::pair, std::shared_ptr> Scene::_ResolveHoveredAutomationTarget() -{ - auto hoverPath = _selector->CurrentHover(); - if (hoverPath.empty()) - return { nullptr, nullptr }; - - const auto stationIndex = hoverPath[0]; - if (stationIndex >= _stations.size()) - return { nullptr, nullptr }; - - auto station = _stations[stationIndex]; - if (!station || station->IsRemote()) - return { nullptr, nullptr }; - - // Prefer the hovered LoopTake; otherwise fall back to the station's first take. - std::shared_ptr take; - if (auto hovered = _ChildFromPath(hoverPath)) - take = std::dynamic_pointer_cast(hovered); - if (!take) - { - const auto& takes = station->GetLoopTakes(); - if (!takes.empty()) - take = takes.front(); - } - if (!take) - return { nullptr, nullptr }; - - const auto& midiLoops = take->GetMidiLoops(); - if (midiLoops.empty() || !midiLoops.front()) - return { nullptr, nullptr }; - - return { station, midiLoops.front() }; -} - -ActionResult Scene::_HandleAutomationKey(actions::KeyAction action) -{ - // All automation commands require Ctrl+Shift to avoid clashing with the bare - // letter keys already bound to loop triggers. - const bool ctrlShift = (Action::MODIFIER_CTRL & action.Modifiers) - && (Action::MODIFIER_SHIFT & action.Modifiers); - const bool isDown = (actions::KeyAction::KEY_DOWN == action.KeyActionType); - const bool isUp = (actions::KeyAction::KEY_UP == action.KeyActionType); - - auto eaten = ActionResult::NoAction(); - eaten.IsEaten = true; - - // 'A' record: begin on Ctrl+Shift+A down, end on A up (regardless of modifier - // so releasing the key always resumes playback). Guarded so unrelated A-ups are - // only consumed when we actually started an automation recording. - if (65 == action.KeyChar) - { - if (isDown && ctrlShift && !_automationRecordKeyHeld) - { - auto [station, loop] = _ResolveHoveredAutomationTarget(); - if (!loop || !station) - return ActionResult::NoAction(); - - _automationRecordKeyHeld = true; - _automationRecordStation = station; - midi::RecordTargetLoop.store(loop.get(), std::memory_order_relaxed); - midi::AutomationRecordHeld.store(true, std::memory_order_relaxed); - std::cout << ">> Automation record armed (Ctrl+Shift+A) <<" << std::endl; - return eaten; - } - if (isUp && _automationRecordKeyHeld) - { - _automationRecordKeyHeld = false; - // Release order: clear the held flag, then publish the dispatch with a - // release fence so the audio thread sees all recorded points. - midi::AutomationRecordHeld.store(false, std::memory_order_relaxed); - midi::RecordTargetLoop.store(nullptr, std::memory_order_relaxed); - if (auto station = _automationRecordStation.lock()) - station->RebuildAutomationDispatch(); - _automationRecordStation.reset(); - std::cout << ">> Automation record released <<" << std::endl; - return eaten; - } - return ActionResult::NoAction(); - } - - // The remaining commands are edge-triggered on Ctrl+Shift + key-down. - if (!isDown || !ctrlShift) - return ActionResult::NoAction(); - - switch (action.KeyChar) - { - case 76: // 'L' - toggle learn mode - { - const bool wasOn = midi::LearnMidiCCMode.load(std::memory_order_relaxed); - const bool nowOn = !wasOn; - midi::LearnMidiCCMode.store(nowOn, std::memory_order_relaxed); - if (!nowOn) - { - // Toggling off clears any stale capture so it can't be wired later. - midi::LearnedCC.store(midi::LearnNothingCaptured, std::memory_order_relaxed); - midi::LearnedChannel.store(midi::LearnNothingCaptured, std::memory_order_relaxed); - } - std::cout << ">> MIDI learn mode " << (nowOn ? "ON" : "OFF") << " <<" << std::endl; - return eaten; - } - case 87: // 'W' - wire the captured CC + last-touched parameter to the selected lane - { - const auto learnedCC = midi::LearnedCC.load(std::memory_order_relaxed); - auto* plugin = vst::_lastTouchedParam.Plugin.load(std::memory_order_relaxed); - if (learnedCC == midi::LearnNothingCaptured || !plugin) - { - std::cout << "Automation wire ignored: no captured CC or touched parameter" << std::endl; - return ActionResult::NoAction(); - } - - auto [station, loop] = _ResolveHoveredAutomationTarget(); - if (!loop || !station) - return ActionResult::NoAction(); - - const auto channel = midi::LearnedChannel.load(std::memory_order_relaxed); - const auto paramIdx = vst::_lastTouchedParam.ParameterIndex.load(std::memory_order_relaxed); - const auto laneIdx = midi::SelectedLaneIndex.load(std::memory_order_relaxed); - - auto& lane = loop->GetLane(laneIdx); - lane.Mapping.TargetPlugin = plugin; - lane.Mapping.TargetParameterIndex = paramIdx; - lane.Mapping.MatchKey.store( - midi::AutomationMapping::MakeMatchKey(channel & 0x0Fu, learnedCC), - std::memory_order_relaxed); - - // Exit learn mode and clear the capture so the next wire is deliberate. - midi::LearnMidiCCMode.store(false, std::memory_order_relaxed); - midi::LearnedCC.store(midi::LearnNothingCaptured, std::memory_order_relaxed); - midi::LearnedChannel.store(midi::LearnNothingCaptured, std::memory_order_relaxed); - - station->RebuildAutomationDispatch(); - std::cout << ">> Automation wired: lane " << static_cast(laneIdx) - << " <- CC " << static_cast(learnedCC) << " ch " << static_cast(channel) - << " -> param " << paramIdx << " <<" << std::endl; - return eaten; - } - case 88: // 'X' - delete the selected lane on the hovered loop - { - auto [station, loop] = _ResolveHoveredAutomationTarget(); - if (!loop || !station) - return ActionResult::NoAction(); - - const auto laneIdx = midi::SelectedLaneIndex.load(std::memory_order_relaxed); - loop->ClearAutomationLane(laneIdx); - station->RebuildAutomationDispatch(); - std::cout << ">> Automation lane " << static_cast(laneIdx) << " cleared <<" << std::endl; - return eaten; - } - case 91: // '[' - select previous lane - { - auto laneIdx = midi::SelectedLaneIndex.load(std::memory_order_relaxed); - laneIdx = (laneIdx == 0u) - ? static_cast(midi::MidiLoop::MaxAutomationLanes - 1u) - : static_cast(laneIdx - 1u); - midi::SelectedLaneIndex.store(laneIdx, std::memory_order_relaxed); - std::cout << ">> Selected automation lane " << static_cast(laneIdx) << " <<" << std::endl; - return eaten; - } - case 93: // ']' - select next lane - { - auto laneIdx = midi::SelectedLaneIndex.load(std::memory_order_relaxed); - laneIdx = static_cast((laneIdx + 1u) % midi::MidiLoop::MaxAutomationLanes); - midi::SelectedLaneIndex.store(laneIdx, std::memory_order_relaxed); - std::cout << ">> Selected automation lane " << static_cast(laneIdx) << " <<" << std::endl; - return eaten; - } - default: - break; - } - - return ActionResult::NoAction(); -} - ActionResult Scene::OnAction(GuiAction action) { switch (action.ElementType) diff --git a/JammaLib/src/engine/Scene.h b/JammaLib/src/engine/Scene.h index f0b2b93b..3cefb0ca 100644 --- a/JammaLib/src/engine/Scene.h +++ b/JammaLib/src/engine/Scene.h @@ -266,12 +266,6 @@ namespace engine void _AddStation(std::shared_ptr station); void _HandleReclockArm(); actions::ActionResult _HandleUndo(); - // MIDI automation interaction (Ctrl+Shift+L/W/X/[/]/A). Returns an eaten - // result when the key was handled as an automation command, NoAction otherwise. - actions::ActionResult _HandleAutomationKey(actions::KeyAction action); - // Resolve the hovered LoopTake's primary MIDI loop and its owning station. - // Returns {nullptr, nullptr} when the hover does not target a MIDI loop. - std::pair, std::shared_ptr> _ResolveHoveredAutomationTarget(); void _SetQuantisation(unsigned int quantiseSamps, utils::Timer::QuantisationType quantisation); void _SetMidiQuantisationGrain(unsigned int grainSamps, const char* source) { _quantisation.SetMidiGrain(grainSamps, source, _stations); @@ -330,10 +324,6 @@ namespace engine std::atomic_bool _isSceneQuitting; std::atomic_bool _isSceneReset; bool _isSceneDragged; - // True while a Ctrl+Shift+A automation recording is in progress (key held). - bool _automationRecordKeyHeld = false; - // Station owning the loop currently being automation-recorded; rebuilt on release. - std::weak_ptr _automationRecordStation; utils::Position2d _initTouchDownPosition; utils::Position3d _initTouchCamPosition; glm::mat4 _viewProj; diff --git a/JammaLib/src/engine/Station.cpp b/JammaLib/src/engine/Station.cpp index a093b892..f1300341 100644 --- a/JammaLib/src/engine/Station.cpp +++ b/JammaLib/src/engine/Station.cpp @@ -511,9 +511,7 @@ void Station::_RunAutomationDispatch(std::uint32_t blockStartSample) noexcept if (!dispatches) return; - // acquire above pairs with the release store in RebuildAutomationDispatch. - const bool recordHeld = midi::AutomationRecordHeld.load(std::memory_order_acquire); - const auto* recordTarget = midi::RecordTargetLoop.load(std::memory_order_relaxed); + const bool recordHeld = midi::MidiRouter::IsAutomationRecordHeld(); const std::uint8_t frontIdx = (dispatches == _automationDispatchBuf[0]) ? 0u : 1u; const auto count = _automationDispatchCount[frontIdx]; @@ -523,9 +521,9 @@ void Station::_RunAutomationDispatch(std::uint32_t blockStartSample) noexcept if (!entry.plugin || !entry.loop) continue; - // Suppress playback only for lanes on the loop actively being recorded; - // other loops' lanes keep playing back unaffected. - if (recordHeld && entry.loop == recordTarget) + // While recording automation, bypass playback writes so incoming CC can own + // parameter movement across all active mappings without tug-of-war. + if (recordHeld) continue; const double frac = (entry.loopLengthSamps > 0u) diff --git a/JammaLib/src/io/IoInputSubsystem.cpp b/JammaLib/src/io/IoInputSubsystem.cpp index 85c18cfd..43d32367 100644 --- a/JammaLib/src/io/IoInputSubsystem.cpp +++ b/JammaLib/src/io/IoInputSubsystem.cpp @@ -57,6 +57,14 @@ namespace io return result; } + actions::ActionResult IoInputSubsystem::HandleAutomationKey(const actions::KeyAction& action, + const std::vector>& stations, + const std::vector& hoverPath, + const std::shared_ptr& hoveredTake) + { + return _midiRouter.HandleAutomationKey(action, stations, hoverPath, hoveredTake); + } + void IoInputSubsystem::RegisterMidiTriggerRoute(const std::string& deviceName, std::shared_ptr trigger) { _midiRouter.RegisterTrigger(deviceName, std::move(trigger)); diff --git a/JammaLib/src/io/IoInputSubsystem.h b/JammaLib/src/io/IoInputSubsystem.h index bedaaf0b..9bd27825 100644 --- a/JammaLib/src/io/IoInputSubsystem.h +++ b/JammaLib/src/io/IoInputSubsystem.h @@ -36,6 +36,11 @@ namespace io const audio::AudioStreamParams& streamParams, std::mutex& audioMutex); + actions::ActionResult HandleAutomationKey(const actions::KeyAction& action, + const std::vector>& stations, + const std::vector& hoverPath, + const std::shared_ptr& hoveredTake); + void RegisterMidiTriggerRoute(const std::string& deviceName, std::shared_ptr trigger); midi::MidiRouter& GetMidiRouterForTest() { return _midiRouter; } diff --git a/JammaLib/src/midi/MidiRouter.cpp b/JammaLib/src/midi/MidiRouter.cpp index c124db7c..2553b662 100644 --- a/JammaLib/src/midi/MidiRouter.cpp +++ b/JammaLib/src/midi/MidiRouter.cpp @@ -3,23 +3,188 @@ #include #include #include +#include "../base/Action.h" +#include "../engine/LoopTake.h" #include "../engine/Station.h" #include "../engine/Trigger.h" #include "../io/UserConfig.h" +#include "../vst/IVstPlugin.h" #include "MidiTimestampMapper.h" #include "MidiLoop.h" using namespace midi; namespace midi { - // Definitions of the interactive automation arming/learn state declared in - // MidiRouter.h. - std::atomic LearnMidiCCMode{ false }; - std::atomic LearnedCC{ LearnNothingCaptured }; - std::atomic LearnedChannel{ LearnNothingCaptured }; - std::atomic AutomationRecordHeld{ false }; - std::atomic SelectedLaneIndex{ 0u }; - std::atomic RecordTargetLoop{ nullptr }; + std::atomic MidiRouter::_automationRecordHeld{ false }; +} + +std::pair, std::shared_ptr> MidiRouter::_ResolveAutomationTarget( + const std::vector>& stations, + const std::vector& hoverPath, + const std::shared_ptr& hoveredTake) const +{ + if (hoverPath.empty()) + return { nullptr, nullptr }; + + const auto stationIndex = hoverPath[0]; + if (stationIndex >= stations.size()) + return { nullptr, nullptr }; + + auto station = stations[stationIndex]; + if (!station || station->IsRemote()) + return { nullptr, nullptr }; + + std::shared_ptr take = hoveredTake; + if (!take) + { + const auto& takes = station->GetLoopTakes(); + if (!takes.empty()) + take = takes.front(); + } + if (!take) + return { nullptr, nullptr }; + + const auto& midiLoops = take->GetMidiLoops(); + if (midiLoops.empty() || !midiLoops.front()) + return { nullptr, nullptr }; + + return { station, midiLoops.front() }; +} + +actions::ActionResult MidiRouter::HandleAutomationKey(const actions::KeyAction& action, + const std::vector>& stations, + const std::vector& hoverPath, + const std::shared_ptr& hoveredTake) +{ + const bool ctrlShift = (base::Action::MODIFIER_CTRL & action.Modifiers) + && (base::Action::MODIFIER_SHIFT & action.Modifiers); + const bool isDown = (actions::KeyAction::KEY_DOWN == action.KeyActionType); + const bool isUp = (actions::KeyAction::KEY_UP == action.KeyActionType); + + auto eaten = actions::ActionResult::NoAction(); + eaten.IsEaten = true; + + if (65 == action.KeyChar) + { + if (isDown && ctrlShift && !_automationRecordKeyHeld) + { + _automationRecordKeyHeld = true; + _automationRecordHeld.store(true, std::memory_order_release); + std::cout << ">> Automation record armed (Ctrl+Shift+A) <<" << std::endl; + return eaten; + } + if (isUp && _automationRecordKeyHeld) + { + _automationRecordKeyHeld = false; + _automationRecordHeld.store(false, std::memory_order_release); + for (const auto& station : stations) + { + if (station && !station->IsRemote()) + station->RebuildAutomationDispatch(); + } + std::cout << ">> Automation record released <<" << std::endl; + return eaten; + } + return actions::ActionResult::NoAction(); + } + + if (!isDown || !ctrlShift) + return actions::ActionResult::NoAction(); + + switch (action.KeyChar) + { + case 76: // 'L' + { + const bool nowOn = !_learnMidiCCMode.load(std::memory_order_relaxed); + _learnMidiCCMode.store(nowOn, std::memory_order_relaxed); + if (!nowOn) + { + _learnedCC.store(LearnNothingCaptured, std::memory_order_relaxed); + _learnedChannel.store(LearnNothingCaptured, std::memory_order_relaxed); + } + std::cout << ">> MIDI learn mode " << (nowOn ? "ON" : "OFF") << " <<" << std::endl; + return eaten; + } + case 87: // 'W' + { + const auto learnedCC = _learnedCC.load(std::memory_order_relaxed); + auto* plugin = vst::_lastTouchedParam.Plugin.load(std::memory_order_relaxed); + if (learnedCC == LearnNothingCaptured || !plugin) + { + std::cout << "Automation wire ignored: no captured CC or touched parameter" << std::endl; + return actions::ActionResult::NoAction(); + } + + auto [station, loop] = _ResolveAutomationTarget(stations, hoverPath, hoveredTake); + if (!loop || !station) + return actions::ActionResult::NoAction(); + + const auto channel = _learnedChannel.load(std::memory_order_relaxed); + const auto paramIdx = vst::_lastTouchedParam.ParameterIndex.load(std::memory_order_relaxed); + const auto laneIdx = _selectedLaneIndex.load(std::memory_order_relaxed); + + auto& lane = loop->GetLane(laneIdx); + lane.Mapping.TargetPlugin = plugin; + lane.Mapping.TargetParameterIndex = paramIdx; + lane.Mapping.MatchKey.store( + midi::AutomationMapping::MakeMatchKey(channel & 0x0Fu, learnedCC), + std::memory_order_relaxed); + + _learnMidiCCMode.store(false, std::memory_order_relaxed); + _learnedCC.store(LearnNothingCaptured, std::memory_order_relaxed); + _learnedChannel.store(LearnNothingCaptured, std::memory_order_relaxed); + + station->RebuildAutomationDispatch(); + std::cout << ">> Automation wired: lane " << static_cast(laneIdx) + << " <- CC " << static_cast(learnedCC) << " ch " << static_cast(channel) + << " -> param " << paramIdx << " <<" << std::endl; + return eaten; + } + case 88: // 'X' + { + auto [station, loop] = _ResolveAutomationTarget(stations, hoverPath, hoveredTake); + if (!loop || !station) + return actions::ActionResult::NoAction(); + + const auto laneIdx = _selectedLaneIndex.load(std::memory_order_relaxed); + loop->ClearAutomationLane(laneIdx); + station->RebuildAutomationDispatch(); + std::cout << ">> Automation lane " << static_cast(laneIdx) << " cleared <<" << std::endl; + return eaten; + } + case 91: // '[' + { + auto laneIdx = _selectedLaneIndex.load(std::memory_order_relaxed); + laneIdx = (laneIdx == 0u) + ? static_cast(midi::MidiLoop::MaxAutomationLanes - 1u) + : static_cast(laneIdx - 1u); + _selectedLaneIndex.store(laneIdx, std::memory_order_relaxed); + std::cout << ">> Selected automation lane " << static_cast(laneIdx) << " <<" << std::endl; + return eaten; + } + case 93: // ']' + { + auto laneIdx = _selectedLaneIndex.load(std::memory_order_relaxed); + laneIdx = static_cast((laneIdx + 1u) % midi::MidiLoop::MaxAutomationLanes); + _selectedLaneIndex.store(laneIdx, std::memory_order_relaxed); + std::cout << ">> Selected automation lane " << static_cast(laneIdx) << " <<" << std::endl; + return eaten; + } + default: + break; + } + + return actions::ActionResult::NoAction(); +} + +bool MidiRouter::IsAutomationRecordHeld() noexcept +{ + return _automationRecordHeld.load(std::memory_order_acquire); +} + +void MidiRouter::SetAutomationRecordHeldForTest(bool held) noexcept +{ + _automationRecordHeld.store(held, std::memory_order_release); } void MidiRouter::InitMidi(const io::UserConfig& cfg, @@ -350,30 +515,44 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector(ingress.data2) / 127.0f; + for (const auto& station : stations) { - const auto loopLen = loop->LoopLengthSamps(); - if (loopLen > 0u) + if (!station || station->IsRemote()) + continue; + + for (const auto& take : station->GetLoopTakes()) { - const auto matchKey = AutomationMapping::MakeMatchKey(channel, cc); - const double frac = - static_cast(globalSampleNow % loopLen) / static_cast(loopLen); - const float value = static_cast(ingress.data2) / 127.0f; - for (std::size_t laneIdx = 0u; laneIdx < MidiLoop::MaxAutomationLanes; ++laneIdx) + if (!take) + continue; + + for (const auto& loop : take->GetMidiLoops()) { - auto& lane = loop->GetLane(laneIdx); - if (lane.Mapping.MatchKey.load(std::memory_order_relaxed) == matchKey) - loop->SetAutomationValueAtFrac(laneIdx, frac, value); + if (!loop) + continue; + + const auto loopLen = loop->LoopLengthSamps(); + if (loopLen == 0u) + continue; + + const double frac = + static_cast(globalSampleNow % loopLen) / static_cast(loopLen); + for (std::size_t laneIdx = 0u; laneIdx < MidiLoop::MaxAutomationLanes; ++laneIdx) + { + auto& lane = loop->GetLane(laneIdx); + if (lane.Mapping.MatchKey.load(std::memory_order_relaxed) == matchKey) + loop->SetAutomationValueAtFrac(laneIdx, frac, value); + } } } } diff --git a/JammaLib/src/midi/MidiRouter.h b/JammaLib/src/midi/MidiRouter.h index 7cab1611..6ecde75e 100644 --- a/JammaLib/src/midi/MidiRouter.h +++ b/JammaLib/src/midi/MidiRouter.h @@ -5,7 +5,10 @@ #include #include #include +#include #include +#include "../actions/ActionResult.h" +#include "../actions/KeyAction.h" #include "../audio/AudioDevice.h" #include "../base/LoggingConfig.h" #include "../io/SerialDevice.h" @@ -21,6 +24,7 @@ namespace io namespace engine { + class LoopTake; class Station; class Trigger; } @@ -29,20 +33,11 @@ namespace midi { class MidiLoop; - // Interactive automation arming/learn state, shared between the UI/action - // thread (Scene key handlers) and the MIDI thread (MidiRouter::PumpMidi). - // All fields are independently atomic; consumers tolerate brief torn reads. - extern std::atomic LearnMidiCCMode; - extern std::atomic LearnedCC; // 0xffu = nothing captured yet - extern std::atomic LearnedChannel; // 0xffu = nothing captured yet - extern std::atomic AutomationRecordHeld; // true while the record key is held - extern std::atomic SelectedLaneIndex; // lane slot W/A/X target (0..MaxAutomationLanes-1) - extern std::atomic RecordTargetLoop; // loop being recorded; nullptr = all lanes play back - static constexpr std::uint8_t LearnNothingCaptured = 0xffu; class MidiRouter - { public: + { + public: struct TriggerDispatchSummary { bool Activated = false; @@ -87,6 +82,14 @@ namespace midi const io::UserConfig& userConfig, const audio::AudioStreamParams& audioParams) noexcept; + actions::ActionResult HandleAutomationKey(const actions::KeyAction& action, + const std::vector>& stations, + const std::vector& hoverPath, + const std::shared_ptr& hoveredTake); + + static bool IsAutomationRecordHeld() noexcept; + static void SetAutomationRecordHeldForTest(bool held) noexcept; + private: static constexpr std::uint8_t UnresolvedMidiDeviceSlot = 0xffu; @@ -110,6 +113,10 @@ namespace midi const midi::MidiEvent& event, const io::UserConfig& userConfig, const audio::AudioStreamParams& audioParams); + std::pair, std::shared_ptr> _ResolveAutomationTarget( + const std::vector>& stations, + const std::vector& hoverPath, + const std::shared_ptr& hoveredTake) const; void _PublishMidiTriggerRoutes(); std::atomic>>> _midiInputs; @@ -119,5 +126,11 @@ namespace midi io::SerialTriggerQueue<256> _serialIngress; std::mutex _serialIngressMutex; std::uint64_t _lastSerialDropCount = 0u; + std::atomic _learnMidiCCMode{ false }; + std::atomic _learnedCC{ LearnNothingCaptured }; + std::atomic _learnedChannel{ LearnNothingCaptured }; + std::atomic _selectedLaneIndex{ 0u }; + bool _automationRecordKeyHeld = false; + static std::atomic _automationRecordHeld; }; } \ No newline at end of file diff --git a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp index 46f0fbf2..c42b2a87 100644 --- a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp +++ b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp @@ -603,15 +603,13 @@ TEST(StationAutomation, RecordingTargetLoopSuppressesPlaybackForThatLoop) std::memory_order_relaxed); station->RebuildAutomationDispatch(); - // While the loop is the active record target, its lanes must not play back. - midi::AutomationRecordHeld.store(true, std::memory_order_relaxed); - midi::RecordTargetLoop.store(midiLoop.get(), std::memory_order_relaxed); + // While automation recording is active, playback writes must be suppressed. + midi::MidiRouter::SetAutomationRecordHeldForTest(true); station->RunAutomationDispatchForTest(0u); EXPECT_EQ(0u, plugin->ParamSetCalls); // Releasing record resumes playback. - midi::AutomationRecordHeld.store(false, std::memory_order_relaxed); - midi::RecordTargetLoop.store(nullptr, std::memory_order_relaxed); + midi::MidiRouter::SetAutomationRecordHeldForTest(false); station->RunAutomationDispatchForTest(0u); EXPECT_GE(plugin->ParamSetCalls, 1u); EXPECT_EQ(1u, plugin->LastParamIndex); From 7661e60f37ab0a019764524db2559295fa1db7a6 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Wed, 17 Jun 2026 14:24:48 +0100 Subject: [PATCH 07/27] Updated plan ready for next session --- .../plan-midiAutomationRecording.prompt.md | 73 +++++++++---------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/.vscode/plan-midiAutomationRecording.prompt.md b/.vscode/plan-midiAutomationRecording.prompt.md index bc7b6020..98078b75 100644 --- a/.vscode/plan-midiAutomationRecording.prompt.md +++ b/.vscode/plan-midiAutomationRecording.prompt.md @@ -17,7 +17,7 @@ This plan is designed to be executed in **two agent sessions** with a clean engi **Implement strictly in order:** 1. **Phase 1** — VST interface + registry. Build clean before moving on. 2. **Phase 2** — `MidiLoop` automation data model. Data structures and interpolation only; no rendering. -3. **Phase 3** — Keyboard arming, MIDI learn hooks, `Scene::OnAction` bindings. +3. **Phase 3** — Keyboard arming and MIDI learn hooks routed via `IoInputSubsystem`/`MidiRouter` (Scene delegates only). 4. **Phase 4** — Flat dispatch list, `_RebuildAutomationDispatch`, `_RunVstBlock` loop. **Stop when:** @@ -34,7 +34,7 @@ This plan is designed to be executed in **two agent sessions** with a clean engi **Prerequisite:** Session 1 complete. Flat dispatch list is live; `SetParameter` is being called correctly. **Implement in order:** -1. **Phase 5** — Confirm `MaxAutomationLanes` wiring through the dispatch rebuild; add any missing `SelectedLaneIndex` / multi-lane recording paths. +1. **Phase 5** — Confirm and harden `MaxAutomationLanes` behavior for multi-lane UX/policy (same-parameter conflicts, lane highlighting, deterministic precedence). 2. **Phase 6** — `automation.vert` / `automation.frag` shaders, `MidiModel` VAO/VBO setup, live control-point texture uploads. **Stop when:** @@ -55,7 +55,9 @@ graph TD classDef nonrt fill:#1a5c1a,stroke:#aaffaa,color:#fff; subgraph User Input - K[Keyboard Key Events] -->|L, W, A, X, brackets| SC[Scene::OnAction] + K[Keyboard Key Events] -->|L, W, A, X, brackets| SC[Scene::OnAction] + SC -->|delegate| IO[IoInputSubsystem::HandleAutomationKey] + IO -->|automation command handling| MR E[Mouse / VST UI GUI] -->|SetParameterAutomated| V2[audioMasterAutomate Callback] M[MIDI Keyboard/Controller] -->|Physical CC Ingress| MR[MidiRouter::PumpMidi] end @@ -67,7 +69,7 @@ graph TD end subgraph Non-Audio Thread Maintenance - SC -->|Wire / Arm / Delete / Release A| RB[Station::_RebuildAutomationDispatch] + MR -->|Wire / Delete / Release A| RB[Station::_RebuildAutomationDispatch] ML -->|Resolve raw ptrs & lane metadata| RB RB -->|atomic swap release| AD[_automationDispatch flat list] end @@ -204,32 +206,29 @@ graph TD ## Phase 3: Keyboard Arming and MIDI Learn Hooks ### [DONE] 3-A: Interactive State Flags -- Add flags inside [JammaLib/src/midi/MidiRouter.h](JammaLib/src/midi/MidiRouter.h): +- Add automation interaction state inside [JammaLib/src/midi/MidiRouter.h](JammaLib/src/midi/MidiRouter.h) as `MidiRouter` members (not namespace globals): ```cpp - namespace midi { - extern std::atomic LearnMidiCCMode; - extern std::atomic LearnedCC; // 0xffu = nothing captured yet - extern std::atomic LearnedChannel; // 0xffu = nothing captured yet - extern std::atomic AutomationRecordHeld; - extern std::atomic SelectedLaneIndex; // which lane slot W/A targets (0..MaxAutomationLanes-1) - extern std::atomic RecordTargetLoop; // loop being recorded; nullptr = all lanes play back - } + std::atomic _learnMidiCCMode{ false }; + std::atomic _learnedCC{ LearnNothingCaptured }; + std::atomic _learnedChannel{ LearnNothingCaptured }; + std::atomic _selectedLaneIndex{ 0u }; + bool _automationRecordKeyHeld = false; + static std::atomic _automationRecordHeld; ``` -- `LearnedCC` and `LearnedChannel` are **written inside `MidiRouter::PumpMidi`** whenever a CC message arrives and `LearnMidiCCMode` is `true`. This is the CC capture step: the user moves a physical knob/slider and the system automatically captures the controller number and channel without any further key press. -- `RecordTargetLoop` is a **raw observer pointer** — its lifetime is externally guaranteed by the `LoopTake` that owns it. It must be cleared to `nullptr` before any `MidiLoop` is destroyed (handled in `LoopTake` teardown). +- `_learnedCC` and `_learnedChannel` are written inside `MidiRouter::PumpMidi` whenever a CC message arrives and `_learnMidiCCMode` is true. +- `_automationRecordHeld` is exposed through `MidiRouter::IsAutomationRecordHeld()` and test-only setter `MidiRouter::SetAutomationRecordHeldForTest(bool)`. ### [DONE] 3-B: Interactive Key Bindings -- Modify `Scene::OnAction(KeyAction)` in [JammaLib/src/engine/Scene.cpp](JammaLib/src/engine/Scene.cpp#L446): - - Key `L` — **Learn Mode** (on key-down): Toggle `LearnMidiCCMode`. When toggling **off**, also reset `LearnedCC` and `LearnedChannel` to `0xffu` so a stale capture cannot be accidentally wired on a future press. +- Scene now delegates automation key handling through `IoInputSubsystem::HandleAutomationKey(...)`, which forwards to `MidiRouter::HandleAutomationKey(...)`. + - Key `L` — **Learn Mode** (on key-down): Toggle `_learnMidiCCMode`. When toggling off, reset `_learnedCC` and `_learnedChannel` to `0xffu`. - Key `W` — **Wire Command** (on key-down): - - Requires `LearnedCC != 0xffu` (a CC was captured) **and** `LastTouchedParam.Plugin != nullptr` (a VST parameter was touched). - - Query the hovered `MidiLoop` via the current selector. If none, do nothing. - - Write to `loop.GetLane(SelectedLaneIndex)`: set `Active = true`, `Channel`, `ControllerNumber`, `TargetPlugin`, `TargetParameterIndex` from the captured state. - - **This writes to the selected lane slot** — it does not append a new one blindly. If slot `SelectedLaneIndex` already had a wiring, it is replaced. To add a second wiring, the user first cycles to the next empty slot with `]` (see below). - - After wiring: exit learn mode (`LearnMidiCCMode = false`, clear `LearnedCC`/`LearnedChannel`), and call `Station::_RebuildAutomationDispatch()` on the non-audio thread. - - Key `X` — **Delete Lane** (on key-down): Clear `loop.GetLane(SelectedLaneIndex)` on the hovered loop — set `Active = false`, reset all fields, erase control points. Trigger `_RebuildAutomationDispatch`. - - Key `[` / `]` — **Cycle Selected Lane** (on key-down): Decrement / increment `SelectedLaneIndex` (wrapping within `0..MaxAutomationLanes-1`). The renderer should highlight the active lane so the user knows which slot they're operating on. - Key `A` — **Automation Record Mode**: Set `AutomationRecordHeld = true` and `RecordTargetLoop = hoveredLoop` **on key-down**; clear both back to `false` / `nullptr` **on key-up**, then immediately call `Station::_RebuildAutomationDispatch()`. The rebuild's `release` store on `_automationDispatch` establishes a happens-before between all MIDI thread writes to `Points` during recording and the audio thread's subsequent `acquire` load of the dispatch pointer. Without this fence, the audio thread could read stale control-point data on the first playback block. The held-key model means recording is always intentional and transient — releasing `A` triggers the fence and immediately resumes playback on that loop's lanes. + - Requires `_learnedCC != 0xffu` and `vst::_lastTouchedParam.Plugin != nullptr`. + - Resolves hovered automation target from station hover path + hovered LoopTake fallback. + - Writes to `loop.GetLane(_selectedLaneIndex)` and rebuilds the owning station dispatch. + - Exits learn mode and clears capture. + - Key `X` — **Delete Lane** (on key-down): Clears `loop.GetLane(_selectedLaneIndex)` on hovered loop and rebuilds that station dispatch. + - Key `[` / `]` — **Cycle Selected Lane** (on key-down): updates `_selectedLaneIndex` with wraparound. + - Key `A` — **Automation Record Mode**: sets `_automationRecordHeld = true` on key-down and false on key-up; key-up rebuilds dispatch for all local stations. Recording is keyed by lane mapping match (`AutomationMapping::MatchKey`), not by a single record-target loop pointer. --- @@ -300,17 +299,14 @@ if (std::abs(val - entry.lastValue) > automationEpsilon) { On a steady-state loop after the first pass, this reduces `SetParameter` calls to zero — the dominant cost on a playing-but-not-moving automation lane. -### [DONE] 4-E: Final audio-thread dispatch loop in `_RunVstBlock` +### [DONE] 4-E: Final audio-thread dispatch loop > **Agent note:** Implemented as `Station::_RunAutomationDispatch(blockStartSample)`, called from `Station::WriteBlock` immediately after `_RunVstBlock`. It is deliberately *not* gated behind `vstActive`, so recorded parameter motion still drives a bypassed/idle chain. Buffer-index derivation, acquire/release fencing, per-loop `fmod`, cursor advance, and the delta gate (`AutomationEpsilon = 1/65536`) match the plan. Verified by `StationAutomation.*` GTests in `test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp`. Test hook: `Station::RunAutomationDispatchForTest`. With the above in place, the audio-thread path collapses to: ```cpp -// acquire pairs with the release store in _RebuildAutomationDispatch, -// ensuring all MIDI-thread writes to Points are visible once recordHeld goes false. -const bool recordHeld = midi::AutomationRecordHeld.load(std::memory_order_acquire); -const MidiLoop* recordTarget = midi::RecordTargetLoop.load(std::memory_order_relaxed); +const bool recordHeld = midi::MidiRouter::IsAutomationRecordHeld(); const auto* dispatches = _automationDispatch.load(std::memory_order_acquire); // Derive front buffer index from which half of _automationDispatchBuf the pointer falls in. const std::uint8_t frontIdx = dispatches ? (dispatches == _automationDispatchBuf[0] ? 0u : 1u) : 0u; @@ -319,9 +315,9 @@ const auto count = dispatches ? _automationDispatchCount[frontIdx] for (auto i = 0u; i < count; ++i) { auto& entry = dispatches[i]; - // Suppress playback only for lanes on the loop that is actively being recorded. - // Other loops' lanes (different MidiLoop*) continue playing back unaffected. - if (recordHeld && entry.loop == recordTarget) continue; + // Suppress playback while automation recording is held, so incoming CC writes + // own parameter movement without playback tug-of-war. + if (recordHeld) continue; // Per-loop fractional position from block-start sample. const double frac = (entry.loopLengthSamps > 0u) @@ -351,15 +347,18 @@ No `weak_ptr::lock`. No shared_ptr deref chain. No per-entry atomic loads. One f ### 5-A: Per-Parameter Automation State - `MidiLoop` already holds a fixed array of `AutomationLane` (capacity `MaxAutomationLanes = 8`, defined in Phase 2-A). Each lane is fully self-contained: its own `AutomationMapping` metadata, its own sparse control-point buffer, and its own point count. No structural changes to `MidiLoop` are required for multi-lane support. -- `SelectedLaneIndex` (Phase 3-A) identifies which slot the user is currently operating on. `RecordTargetLoop` (Phase 3-A) identifies which loop is actively recording. Together they make multi-lane, multi-loop operation deterministic with no ambiguity. +- `_selectedLaneIndex` identifies which slot the user is currently operating on. +- Recording is now mapping-driven across all local stations while `_automationRecordHeld` is true; there is no single `RecordTargetLoop` pointer. - This allows: - - simultaneous recording of several parameters from the same loop (press `]` to move to next lane slot, wire each with W, then hold A to record all active lanes at once since `recordHeld` suppresses all of the target loop's lanes), + - simultaneous recording of several parameters from the same loop, + - simultaneous recording of mapped parameters across multiple stations, - independent wiring of different CCs/controllers to different parameters (each lane stores its own `ControllerNumber`/`Channel`), - and independent playback of each mapped parameter via the flat dispatch list. ### 5-B: Simultaneous / Independent Recording -- When recording is active, incoming MIDI CC values should be routed to whichever automation mapping is currently selected or currently armed for that controller/channel. -- A single loop may record multiple automation lanes at once if multiple mappings are armed. +- When recording is active, incoming MIDI CC values are routed to every active mapping with matching `(channel, cc)` across all local stations and their MIDI loops. +- A single loop may record multiple automation lanes at once if multiple mappings match. +- Multiple stations may record at the same time if they contain matching active mappings. - Each mapping should keep its own timeline of sparse control points, so one parameter’s curve can evolve independently of another’s. - The UI/interaction layer should make it clear which automation lane is currently being learned or recorded. From c7893787a427d72e17a35a5672a077953436222e Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Wed, 17 Jun 2026 15:19:18 +0100 Subject: [PATCH 08/27] Implement MIDI automation rendering, and update lane management --- .../plan-midiAutomationRecording.prompt.md | 18 +- Jamma/Jamma.vcxproj | 8 + Jamma/Jamma.vcxproj.filters | 6 + Jamma/resources/ResourceList.txt | 1 + Jamma/resources/shaders/automation.frag | 76 ++++++ Jamma/resources/shaders/automation.vert | 80 ++++++ JammaLib/src/graphics/MidiModel.cpp | 240 +++++++++++++++++- JammaLib/src/graphics/MidiModel.h | 35 +++ JammaLib/src/midi/MidiLoop.cpp | 74 +++++- JammaLib/src/midi/MidiLoop.h | 19 ++ 10 files changed, 536 insertions(+), 21 deletions(-) create mode 100644 Jamma/resources/shaders/automation.frag create mode 100644 Jamma/resources/shaders/automation.vert diff --git a/.vscode/plan-midiAutomationRecording.prompt.md b/.vscode/plan-midiAutomationRecording.prompt.md index 98078b75..cf448ec7 100644 --- a/.vscode/plan-midiAutomationRecording.prompt.md +++ b/.vscode/plan-midiAutomationRecording.prompt.md @@ -345,7 +345,7 @@ No `weak_ptr::lock`. No shared_ptr deref chain. No per-entry atomic loads. One f ## Phase 5: Multiple Automation Parameters and Multi-Playback -### 5-A: Per-Parameter Automation State +### [DONE] 5-A: Per-Parameter Automation State - `MidiLoop` already holds a fixed array of `AutomationLane` (capacity `MaxAutomationLanes = 8`, defined in Phase 2-A). Each lane is fully self-contained: its own `AutomationMapping` metadata, its own sparse control-point buffer, and its own point count. No structural changes to `MidiLoop` are required for multi-lane support. - `_selectedLaneIndex` identifies which slot the user is currently operating on. - Recording is now mapping-driven across all local stations while `_automationRecordHeld` is true; there is no single `RecordTargetLoop` pointer. @@ -355,14 +355,14 @@ No `weak_ptr::lock`. No shared_ptr deref chain. No per-entry atomic loads. One f - independent wiring of different CCs/controllers to different parameters (each lane stores its own `ControllerNumber`/`Channel`), - and independent playback of each mapped parameter via the flat dispatch list. -### 5-B: Simultaneous / Independent Recording +### [DONE] 5-B: Simultaneous / Independent Recording - When recording is active, incoming MIDI CC values are routed to every active mapping with matching `(channel, cc)` across all local stations and their MIDI loops. - A single loop may record multiple automation lanes at once if multiple mappings match. - Multiple stations may record at the same time if they contain matching active mappings. - Each mapping should keep its own timeline of sparse control points, so one parameter’s curve can evolve independently of another’s. - The UI/interaction layer should make it clear which automation lane is currently being learned or recorded. -### 5-C: Multi-Playback Routing +### [DONE] 5-C: Multi-Playback Routing - During playback, evaluate all active automation mappings for a loop and apply each mapped value to its target plugin parameter. - Playback should be additive/independent per mapping, not a single shared curve. - If multiple automation mappings target the same plugin parameter, the system should either: @@ -370,7 +370,7 @@ No `weak_ptr::lock`. No shared_ptr deref chain. No per-entry atomic loads. One f - define a clear precedence rule such as “last wired mapping wins” or “multi-lane blend.” - The initial implementation should favor deterministic, easy-to-reason-about behavior: evaluate mappings in insertion order and apply each to its target parameter. -### 5-D: Rendering Multiple Automation Curves +### [DONE] 5-D: Rendering Multiple Automation Curves - The display should support rendering multiple automation lanes for the same loop, each with its own texture-backed curve. - One vertex shader uniform (Radius) and one fragment shader uniform (Color) are used to distinguish different parameters/lanes in the 3D visualization. - The shader path should remain generic: one automation curve texture can drive one visual band/ribbon, and multiple bands can be drawn for multiple mapped parameters. @@ -379,13 +379,19 @@ No `weak_ptr::lock`. No shared_ptr deref chain. No per-entry atomic loads. One f ## Phase 6: Holographic 3D Undulating Display -### 6-A: Pipeline Shader Modifications +### [DONE] 6-A: Pipeline Shader Modifications + +> **Agent note:** Implemented with a `uniform vec2 AutoPoints[256]` array instead of an N×2 lookup *texture*. Functionally equivalent (piecewise-linear interp in the vertex shader), but avoids a new `TextureResource` subclass and per-frame `glTexImage2D` uploads. Control points are snapshotted from the live `MidiLoop` lane each draw via a seqlock reader (`SnapshotAutomationLanePoints`) and pushed as uniforms. Single shader pair handles all four primitives via an `int RenderMode` uniform (0 curtain / 1 crown / 2 playhead / 3 dot). + - Store shader pipeline configs [Jamma/resources/shaders/automation.vert](Jamma/resources/shaders/automation.vert) and [Jamma/resources/shaders/automation.frag](Jamma/resources/shaders/automation.frag): - **Vertex Shader**: Use the automation lookup texture to sample the curve by the vertex’s loop position, then offset the geometry along the circular trajectory using the sampled height value. - **Fragment Shader**: Apply a color gradient (circumferentially, based on uv coordinates, so color and alpha fade with loop time, brightest at current play position). Also feature bright highlighted thick top edge and vertical play position. Should brighten when recording is active (frag uniform). - The vertex shader should perform piecewise-linear interpolation across the sparse control points stored in the $N \times 2$ texture (first coord is time [0:1], second coord is automation height value), so the display updates live while recording without CPU resampling. -### 6-B: VAO Setup inside `MidiModel` +### [DONE] 6-B: VAO Setup inside `MidiModel` + +> **Agent note:** Four static VAO/VBO pairs built once in `_InitAutomationGl` (curtain triangle-strip, crown line-loop, playhead line, dot point). `MidiModel` caches a `const midi::MidiLoop*` back-pointer set in `MidiLoop::AttachModel`. Per-lane draw loop in `_DrawAutomation` snapshots points, sets per-lane radius/height/colour uniforms, and issues the four draws. No mesh regeneration per frame; only uniforms change. "Texture re-upload on change" requirement is satisfied by the per-draw uniform snapshot. + - Update [JammaLib/src/graphics/MidiModel.h](JammaLib/src/graphics/MidiModel.h) and [JammaLib/src/graphics/MidiModel.cpp](JammaLib/src/graphics/MidiModel.cpp): - Cache a reference pointer to `midi::MidiLoop` in `MidiModel`. - Build a fixed mesh once for the automation display, with the geometry defined in a reusable VAO/VBO pair. UV's normalised to the loop length / full arc. The main mesh should be a circular curtain with a small vertical height (scaled per LoopTake height), and the vertices should be arranged in a triangle strip around the circumference. diff --git a/Jamma/Jamma.vcxproj b/Jamma/Jamma.vcxproj index d5104364..87a1f097 100644 --- a/Jamma/Jamma.vcxproj +++ b/Jamma/Jamma.vcxproj @@ -255,6 +255,14 @@ + + true + PreserveNewest + + + true + PreserveNewest + true PreserveNewest diff --git a/Jamma/Jamma.vcxproj.filters b/Jamma/Jamma.vcxproj.filters index ce297e40..f0d884aa 100644 --- a/Jamma/Jamma.vcxproj.filters +++ b/Jamma/Jamma.vcxproj.filters @@ -90,6 +90,12 @@ + + resources\shaders + + + resources\shaders + resources\shaders diff --git a/Jamma/resources/ResourceList.txt b/Jamma/resources/ResourceList.txt index 68116390..a158c31f 100644 --- a/Jamma/resources/ResourceList.txt +++ b/Jamma/resources/ResourceList.txt @@ -7,6 +7,7 @@ 2 waveform MVP LoopState LoopHover TextureSampler WaveformSampler WaveformRadius WaveformHeightScale WaveformMinHeight WaveformColorMultiplier WaveformColorScale WaveformUnitMeshRadius 2 vu MVP DX DY NumInstances InstanceOffset 2 midi_note MVP ObjectId Highlight LoopHover DiscAlpha RenderMode +2 automation MVP 2 picker MVP ObjectId WaveformRadius WaveformUnitMeshRadius 2 colour MVP Color 2 quantisation MVP Highlight OverlayAlpha diff --git a/Jamma/resources/shaders/automation.frag b/Jamma/resources/shaders/automation.frag new file mode 100644 index 00000000..bd03041a --- /dev/null +++ b/Jamma/resources/shaders/automation.frag @@ -0,0 +1,76 @@ +#version 330 core + +in float vT; // loop position 0..1 +in float vEdge; // 0 base .. 1 top +in float vHeight; // sampled automation value 0..1 + +out vec4 ColorOUT; + +uniform vec3 LaneColor; +uniform float RecordGlow; // 0..1, lifts brightness while recording +uniform float PlayFrac; +uniform int RenderMode; // 0 curtain, 1 crown, 2 playhead, 3 dot + +const int RenderModeCurtain = 0; +const int RenderModeCrown = 1; +const int RenderModePlayhead = 2; +const int RenderModeDot = 3; + +// Wrapped distance from the play head in loop space, mapped so the curve is +// brightest at the current play position and fades around the ring (loop time). +float PlayTrail(float t) +{ + float d = abs(t - PlayFrac); + d = min(d, 1.0 - d); // shortest way around the circle, 0..0.5 + return 1.0 - smoothstep(0.0, 0.5, d); +} + +void main() +{ + float trail = PlayTrail(vT); + + if (RenderMode == RenderModeDot) + { + // Soft glowing play marker with a bright core and falloff halo. + vec2 d = gl_PointCoord - vec2(0.5); + float r = length(d) * 2.0; + if (r > 1.0) + discard; + float core = 1.0 - smoothstep(0.0, 0.35, r); + float halo = 1.0 - smoothstep(0.35, 1.0, r); + vec3 col = mix(LaneColor, vec3(1.0), core * 0.8) + RecordGlow * 0.4; + float a = clamp(core + halo * 0.5, 0.0, 1.0); + ColorOUT = vec4(col, a); + return; + } + + if (RenderMode == RenderModePlayhead) + { + // Thin bright vertical line at the play position, fading toward the base. + vec3 col = mix(LaneColor * 1.4, vec3(1.0), 0.5) + RecordGlow * 0.5; + float a = mix(0.15, 0.95, vEdge); + ColorOUT = vec4(col, a); + return; + } + + if (RenderMode == RenderModeCrown) + { + // Glowing top ring crown: bright, pulses up while recording. + vec3 col = LaneColor * 1.6 + vec3(0.25) + RecordGlow * 0.6; + float a = clamp(0.45 + 0.55 * trail + RecordGlow * 0.2, 0.0, 1.0); + ColorOUT = vec4(col, a); + return; + } + + // Curtain: height-tinted body, brightest near the play head, with a bright + // top edge band and a recording lift. + vec3 low = LaneColor * 0.35; + vec3 body = mix(low, LaneColor, vHeight); + float topBand = smoothstep(0.82, 1.0, vEdge); + vec3 col = body + topBand * (LaneColor * 0.6 + vec3(0.35)); + col += RecordGlow * 0.35 * (0.4 + 0.6 * trail); + + float alpha = mix(0.10, 0.55, trail); + alpha = clamp(alpha + topBand * 0.4 + RecordGlow * 0.15, 0.0, 0.9); + ColorOUT = vec4(col, alpha); +} diff --git a/Jamma/resources/shaders/automation.vert b/Jamma/resources/shaders/automation.vert new file mode 100644 index 00000000..4c9f5150 --- /dev/null +++ b/Jamma/resources/shaders/automation.vert @@ -0,0 +1,80 @@ +#version 330 core + +// Single attribute: x = circumferential position t in [0,1] around the loop, +// y = vertical edge selector (0 = curtain base, 1 = curtain top / crown). +layout(location = 0) in vec2 ParamIN; + +out float vT; // loop position 0..1 +out float vEdge; // 0 base .. 1 top +out float vHeight; // sampled automation value 0..1 + +uniform mat4 MVP; + +// Sparse control points (frac, value), piecewise-linear in loop space. +uniform vec2 AutoPoints[256]; +uniform int AutoPointCount; + +uniform float LaneRadius; +uniform float LaneHeight; +uniform float PlayFrac; +uniform int RenderMode; // 0 curtain, 1 crown, 2 playhead, 3 dot + +const float TwoPi = 6.28318530718; + +const int RenderModeCurtain = 0; +const int RenderModeCrown = 1; +const int RenderModePlayhead = 2; +const int RenderModeDot = 3; + +float SampleAutomation(float t) +{ + if (AutoPointCount <= 0) + return 0.0; + if (t <= AutoPoints[0].x) + return AutoPoints[0].y; + + int last = AutoPointCount - 1; + if (t >= AutoPoints[last].x) + return AutoPoints[last].y; + + for (int i = 0; i < 255; ++i) + { + if (i + 1 > last) + break; + + vec2 lo = AutoPoints[i]; + vec2 hi = AutoPoints[i + 1]; + if (t >= lo.x && t <= hi.x) + { + float span = hi.x - lo.x; + if (span <= 0.0) + return hi.y; + float f = (t - lo.x) / span; + return mix(lo.y, hi.y, f); + } + } + return AutoPoints[last].y; +} + +void main() +{ + // Playhead and dot are pinned to the current play position; curtain and crown + // sweep the whole loop using their per-vertex t. + float t = (RenderMode >= RenderModePlayhead) ? PlayFrac : ParamIN.x; + float edge = ParamIN.y; + + float h = SampleAutomation(t); + vT = t; + vEdge = edge; + vHeight = h; + + float angle = TwoPi * t; + float yTop = LaneHeight * h; + float y = mix(0.0, yTop, edge); + + vec3 position = vec3(sin(angle) * LaneRadius, y, cos(angle) * LaneRadius); + gl_Position = MVP * vec4(position, 1.0); + + if (RenderMode == RenderModeDot) + gl_PointSize = 18.0; +} diff --git a/JammaLib/src/graphics/MidiModel.cpp b/JammaLib/src/graphics/MidiModel.cpp index 3f6f44ce..c30a713e 100644 --- a/JammaLib/src/graphics/MidiModel.cpp +++ b/JammaLib/src/graphics/MidiModel.cpp @@ -1,11 +1,17 @@ #include "MidiModel.h" #include +#include #include #include #include "../include/Constants.h" #include "GlDrawContext.h" +#include "GlDeleteQueue.h" +#include "../midi/MidiLoop.h" +#include "../midi/MidiRouter.h" +#include "../resources/ResourceLib.h" +#include "../resources/ShaderResource.h" #include "../utils/VecUtils.h" using namespace graphics; @@ -18,6 +24,10 @@ namespace static constexpr unsigned int TimePitchAttribute = 3u; static constexpr unsigned int ShapeAttribute = 4u; + // Automation curtain tessellation around the loop circumference. Higher counts + // give a smoother undulating ribbon at the cost of more vertices (built once). + static constexpr unsigned int AutomationArcSegments = 160u; + void AddTri(std::vector& verts, float x1, float y1, float z1, float x2, float y2, float z2, @@ -81,7 +91,21 @@ MidiModel::MidiModel(MidiModelParams params) _midiParams(params), _loopIndexFrac(0.0), _backNoteInstanceCount(0u), - _pendingModelUpdate(nullptr) + _pendingModelUpdate(nullptr), + _automationSource(nullptr), + _displayLengthSamps(0u), + _automationGlReady(false), + _automationShader(), + _curtainVao(0u), + _curtainVbo(0u), + _curtainVertCount(0u), + _crownVao(0u), + _crownVbo(0u), + _crownVertCount(0u), + _playVao(0u), + _playVbo(0u), + _dotVao(0u), + _dotVbo(0u) { // Emit a disc at the minimum radius so the loop target is visible // from the moment it is created (before the loop length is known). @@ -100,6 +124,7 @@ MidiModel::MidiModel(MidiModelParams params) MidiModel::~MidiModel() { + _ReleaseAutomationGl(); } void MidiModel::Draw3d(DrawContext& ctx, unsigned int numInstances, base::DrawPass pass) @@ -147,6 +172,8 @@ void MidiModel::Draw3d(DrawContext& ctx, unsigned int numInstances, base::DrawPa glCtx.SetUniform("RenderMode", 4); GuiModel::Draw3d(glCtx, numInstances, pass); + _DrawAutomation(glCtx); + glDepthMask(prevDepthMask); } else @@ -163,6 +190,7 @@ void MidiModel::SetLoopIndexFrac(double frac) noexcept void MidiModel::UpdateModel(const std::vector& spans, std::uint32_t loopLengthSamps) { + _displayLengthSamps.store(loopLengthSamps, std::memory_order_relaxed); auto data = BuildInstanceData(spans, loopLengthSamps); _backNoteInstanceCount = data->NoteCount; SetInstanceAttributes(std::move(data->Attributes), data->InstanceCount); @@ -170,6 +198,7 @@ void MidiModel::UpdateModel(const std::vector& spans, std::uint3 void MidiModel::QueueModelUpdate(const std::vector& spans, std::uint32_t loopLengthSamps) { + _displayLengthSamps.store(loopLengthSamps, std::memory_order_relaxed); _pendingModelUpdate.store(BuildInstanceData(spans, loopLengthSamps), std::memory_order_release); } @@ -262,12 +291,215 @@ std::weak_ptr MidiModel::GetShader() return std::weak_ptr(); } +void MidiModel::_InitResources(resources::ResourceLib& resourceLib, bool forceInit) +{ + GuiModel::_InitResources(resourceLib, forceInit); + _InitAutomationGl(resourceLib); +} + +void MidiModel::_ReleaseResources() +{ + GuiModel::_ReleaseResources(); + _ReleaseAutomationGl(); +} + +void MidiModel::_InitAutomationGl(resources::ResourceLib& resourceLib) +{ + if (_automationGlReady || !HasCurrentGlContext()) + return; + + if (auto resOpt = resourceLib.GetResource("automation"); resOpt.has_value()) + { + if (auto res = resOpt.value().lock(); res && resources::SHADER == res->GetType()) + _automationShader = std::dynamic_pointer_cast(res); + } + + // Curtain: a closed triangle strip of bottom/top vertex pairs around the loop. + std::vector curtain; + curtain.reserve((AutomationArcSegments + 1u) * 4u); + for (unsigned int i = 0u; i <= AutomationArcSegments; ++i) + { + const float t = static_cast(i) / static_cast(AutomationArcSegments); + curtain.push_back(t); curtain.push_back(0.0f); // base + curtain.push_back(t); curtain.push_back(1.0f); // top + } + _curtainVertCount = (AutomationArcSegments + 1u) * 2u; + + // Crown: the top edge as a closed line loop. + std::vector crown; + crown.reserve(AutomationArcSegments * 2u); + for (unsigned int i = 0u; i < AutomationArcSegments; ++i) + { + const float t = static_cast(i) / static_cast(AutomationArcSegments); + crown.push_back(t); crown.push_back(1.0f); + } + _crownVertCount = AutomationArcSegments; + + const float play[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; + const float dot[2] = { 0.0f, 1.0f }; + + const auto makeVao = [](GLuint& vao, GLuint& vbo, const float* data, std::size_t floatCount) + { + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, floatCount * sizeof(GLfloat), data, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, 0); + glBindVertexArray(0); + }; + + makeVao(_curtainVao, _curtainVbo, curtain.data(), curtain.size()); + makeVao(_crownVao, _crownVbo, crown.data(), crown.size()); + makeVao(_playVao, _playVbo, play, 4u); + makeVao(_dotVao, _dotVbo, dot, 2u); + + glBindBuffer(GL_ARRAY_BUFFER, 0); + _automationGlReady = true; +} + +void MidiModel::_ReleaseAutomationGl() +{ + const auto dropBuffer = [](GLuint& id) + { + if (id != 0u) + { + graphics::GlDeleteQueue::DeleteBuffers(1, &id); + id = 0u; + } + }; + const auto dropVao = [](GLuint& id) + { + if (id != 0u) + { + graphics::GlDeleteQueue::DeleteVertexArrays(1, &id); + id = 0u; + } + }; + + dropBuffer(_curtainVbo); dropVao(_curtainVao); + dropBuffer(_crownVbo); dropVao(_crownVao); + dropBuffer(_playVbo); dropVao(_playVao); + dropBuffer(_dotVbo); dropVao(_dotVao); + + _curtainVertCount = 0u; + _crownVertCount = 0u; + _automationGlReady = false; +} + +void MidiModel::_DrawAutomation(GlDrawContext& glCtx) +{ + if (!_automationSource) + return; + + auto shader = _automationShader.lock(); + if (!shader || 0u == _curtainVao) + return; + + const auto lengthSamps = _displayLengthSamps.load(std::memory_order_relaxed); + if (0u == lengthSamps) + return; + + // Reproduce the placement GuiModel applies to the note instances so the curtain + // sits concentric with the note ring. + const auto pos = ModelPosition(); + const auto scale = ModelScale(); + glCtx.PushMvp(glm::translate(glm::mat4(1.0), glm::vec3(pos.X, pos.Y, pos.Z))); + glCtx.PushMvp(glm::scale(glm::mat4(1.0), glm::vec3(scale, scale, scale))); + + const double rawRadius = 70.0 * std::log(static_cast(lengthSamps)) - 600.0; + const float baseRadius = static_cast(std::clamp(rawRadius, 50.0, 400.0)); + const float laneHeight = baseRadius * 0.16f; + const bool recording = midi::MidiRouter::IsAutomationRecordHeld(); + const float playFrac = static_cast(_loopIndexFrac); + + const GLuint prog = shader->GetId(); + glUseProgram(prog); + shader->SetUniforms(glCtx); // MVP + + const GLint locPoints = glGetUniformLocation(prog, "AutoPoints"); + const GLint locCount = glGetUniformLocation(prog, "AutoPointCount"); + const GLint locRadius = glGetUniformLocation(prog, "LaneRadius"); + const GLint locHeight = glGetUniformLocation(prog, "LaneHeight"); + const GLint locColor = glGetUniformLocation(prog, "LaneColor"); + const GLint locGlow = glGetUniformLocation(prog, "RecordGlow"); + const GLint locPlay = glGetUniformLocation(prog, "PlayFrac"); + const GLint locMode = glGetUniformLocation(prog, "RenderMode"); + + glUniform1f(locPlay, playFrac); + + GLboolean prevDepthMask = GL_TRUE; + glGetBooleanv(GL_DEPTH_WRITEMASK, &prevDepthMask); + const GLboolean prevBlend = glIsEnabled(GL_BLEND); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glDepthMask(GL_FALSE); + glEnable(GL_PROGRAM_POINT_SIZE); + + static const std::array palette = { { + { 0.20f, 0.85f, 1.00f }, { 1.00f, 0.55f, 0.20f }, { 0.55f, 1.00f, 0.45f }, { 1.00f, 0.35f, 0.65f }, + { 0.70f, 0.55f, 1.00f }, { 1.00f, 0.90f, 0.30f }, { 0.30f, 0.95f, 0.80f }, { 0.95f, 0.45f, 0.40f } + } }; + + std::array, midi::AutomationLane::MaxPoints> pts; + std::array flat; + + for (std::size_t lane = 0u; lane < midi::MidiLoop::MaxAutomationLanes; ++lane) + { + const bool active = _automationSource->IsAutomationLaneActive(lane); + const auto count = _automationSource->SnapshotAutomationLanePoints(lane, pts.data(), pts.size()); + if (!active && 0u == count) + continue; + + for (std::uint16_t i = 0u; i < count; ++i) + { + flat[i * 2u] = pts[i].first; + flat[i * 2u + 1u] = pts[i].second; + } + + const float laneRadius = baseRadius * (1.05f + static_cast(lane) * 0.045f); + const auto& col = palette[lane % palette.size()]; + + glUniform2fv(locPoints, count, flat.data()); + glUniform1i(locCount, static_cast(count)); + glUniform1f(locRadius, laneRadius); + glUniform1f(locHeight, laneHeight); + glUniform3f(locColor, col.x, col.y, col.z); + glUniform1f(locGlow, (active && recording) ? 1.0f : 0.0f); + + glUniform1i(locMode, 0); // Curtain + glBindVertexArray(_curtainVao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, static_cast(_curtainVertCount)); + + glUniform1i(locMode, 1); // Crown ring + glBindVertexArray(_crownVao); + glDrawArrays(GL_LINE_LOOP, 0, static_cast(_crownVertCount)); + + glUniform1i(locMode, 2); // Playhead line + glBindVertexArray(_playVao); + glDrawArrays(GL_LINES, 0, 2); + + glUniform1i(locMode, 3); // Play dot + glBindVertexArray(_dotVao); + glDrawArrays(GL_POINTS, 0, 1); + } + + glBindVertexArray(0); + glUseProgram(0); + + glDisable(GL_PROGRAM_POINT_SIZE); + if (!prevBlend) + glDisable(GL_BLEND); + glDepthMask(prevDepthMask); + + glCtx.PopMvp(); + glCtx.PopMvp(); +} + std::vector MidiModel::BuildBaseVerts(unsigned int segments) { std::vector verts; - if (0u == segments) - return verts; - verts.reserve((segments * 8u + 4u) * 9u); for (auto segment = 0u; segment < segments; ++segment) diff --git a/JammaLib/src/graphics/MidiModel.h b/JammaLib/src/graphics/MidiModel.h index 1b6b583f..3e2b10c5 100644 --- a/JammaLib/src/graphics/MidiModel.h +++ b/JammaLib/src/graphics/MidiModel.h @@ -3,13 +3,21 @@ #include #include #include +#include #include #include "../gui/GuiModel.h" #include "../midi/MidiNote.h" +namespace midi +{ + class MidiLoop; +} + namespace graphics { + class GlDrawContext; + class MidiModelParams : public gui::GuiModelParams { public: @@ -57,8 +65,14 @@ namespace graphics static std::vector BuildBaseVerts(unsigned int segments); static std::vector BuildBaseUvs(unsigned int segments); + // Back-pointer to the owning loop so the renderer can read automation lanes. + // The loop owns this model (shared_ptr), so the raw pointer outlives the model. + void SetAutomationSource(const midi::MidiLoop* loop) noexcept { _automationSource = loop; } + protected: std::weak_ptr GetShader() override; + void _InitResources(resources::ResourceLib& resourceLib, bool forceInit) override; + void _ReleaseResources() override; private: std::shared_ptr BuildInstanceData(const std::vector& spans, @@ -66,10 +80,31 @@ namespace graphics void ApplyPendingModelUpdate(); float PitchOffset(std::uint8_t note) const noexcept; + // --- Automation curtain rendering --- + void _InitAutomationGl(resources::ResourceLib& resourceLib); + void _ReleaseAutomationGl(); + void _DrawAutomation(GlDrawContext& glCtx); + private: MidiModelParams _midiParams; double _loopIndexFrac; unsigned int _backNoteInstanceCount; std::atomic> _pendingModelUpdate; + + // Automation display state. GL objects live on the render thread only. + const midi::MidiLoop* _automationSource; + std::atomic _displayLengthSamps; + bool _automationGlReady; + std::weak_ptr _automationShader; + GLuint _curtainVao; + GLuint _curtainVbo; + unsigned int _curtainVertCount; + GLuint _crownVao; + GLuint _crownVbo; + unsigned int _crownVertCount; + GLuint _playVao; + GLuint _playVbo; + GLuint _dotVao; + GLuint _dotVbo; }; } \ No newline at end of file diff --git a/JammaLib/src/midi/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index 877d103b..fce69e20 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -145,6 +145,8 @@ bool MidiLoop::TryGetEvent(std::size_t index, MidiEvent& ev) const noexcept void MidiLoop::AttachModel(std::shared_ptr model) noexcept { _model = std::move(model); + if (_model) + _model->SetAutomationSource(this); _modelRevision = 0u; _modelLengthSamps = 0u; } @@ -377,6 +379,11 @@ void MidiLoop::SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float auto& points = lane.Points; auto& count = lane.PointCount; + // Open the seqlock (odd) so a concurrent render-thread snapshot retries rather + // than observing a half-shifted buffer, then close it (even) on every exit. + const auto gen = lane.Revision.load(std::memory_order_relaxed); + lane.Revision.store(gen + 1u, std::memory_order_release); + // Find the insertion index (points are kept sorted by frac ascending). std::size_t insertAt = 0u; while (insertAt < count && points[insertAt].first < fracF) @@ -386,22 +393,25 @@ void MidiLoop::SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float if (insertAt < count && (points[insertAt].first - fracF) <= fracEpsilon) { points[insertAt].second = value; - return; } - if (insertAt > 0u && (fracF - points[insertAt - 1u].first) <= fracEpsilon) + else if (insertAt > 0u && (fracF - points[insertAt - 1u].first) <= fracEpsilon) { points[insertAt - 1u].second = value; - return; + } + else if (count >= AutomationLane::MaxPoints) + { + // Buffer full: drop newest. + } + else + { + // Shift tail right to make room, then insert. Fixed-capacity, no allocation. + for (std::size_t i = count; i > insertAt; --i) + points[i] = points[i - 1u]; + points[insertAt] = std::make_pair(fracF, value); + ++count; } - if (count >= AutomationLane::MaxPoints) - return; // Buffer full: drop newest. - - // Shift tail right to make room, then insert. Fixed-capacity, no allocation. - for (std::size_t i = count; i > insertAt; --i) - points[i] = points[i - 1u]; - points[insertAt] = std::make_pair(fracF, value); - ++count; + lane.Revision.store(gen + 2u, std::memory_order_release); } float MidiLoop::GetAutomationValueAtCursor(std::size_t laneIdx, double frac, std::uint16_t& cursorIdx) const noexcept @@ -449,8 +459,50 @@ void MidiLoop::ClearAutomationLane(std::size_t laneIdx) noexcept return; auto& lane = _lanes[laneIdx]; + const auto gen = lane.Revision.load(std::memory_order_relaxed); + lane.Revision.store(gen + 1u, std::memory_order_release); lane.Mapping.MatchKey.store(AutomationMapping::kInactive, std::memory_order_relaxed); lane.Mapping.TargetPlugin = nullptr; lane.Mapping.TargetParameterIndex = 0u; lane.PointCount = 0u; + lane.Revision.store(gen + 2u, std::memory_order_release); +} + +std::uint16_t MidiLoop::SnapshotAutomationLanePoints(std::size_t laneIdx, + std::pair* out, std::size_t maxPoints) const noexcept +{ + if (laneIdx >= MaxAutomationLanes || !out || 0u == maxPoints) + return 0u; + + const auto& lane = _lanes[laneIdx]; + + // Seqlock read: retry while the writer holds the lock (odd generation) or the + // generation changed mid-copy. Bounded retries — this is a display path, so a + // rare give-up returning the latest partial copy is acceptable. + for (int attempt = 0; attempt < 8; ++attempt) + { + const auto gen0 = lane.Revision.load(std::memory_order_acquire); + if (gen0 & 1u) + continue; // Writer in progress. + + auto count = lane.PointCount; + if (count > maxPoints) + count = maxPoints; + for (std::size_t i = 0u; i < count; ++i) + out[i] = lane.Points[i]; + + const auto gen1 = lane.Revision.load(std::memory_order_acquire); + if (gen0 == gen1) + return static_cast(count); + } + + return 0u; +} + +bool MidiLoop::IsAutomationLaneActive(std::size_t laneIdx) const noexcept +{ + if (laneIdx >= MaxAutomationLanes) + return false; + + return _lanes[laneIdx].Mapping.IsActive(); } diff --git a/JammaLib/src/midi/MidiLoop.h b/JammaLib/src/midi/MidiLoop.h index 3bf0aec5..dcd08d7f 100644 --- a/JammaLib/src/midi/MidiLoop.h +++ b/JammaLib/src/midi/MidiLoop.h @@ -91,6 +91,14 @@ namespace midi AutomationMapping Mapping; std::array, MaxPoints> Points{}; // (frac, value) std::size_t PointCount = 0u; + + // Seqlock generation counter. The MIDI thread (the only writer of Points / + // PointCount) bumps this to an odd value before mutating the buffer and back + // to an even value afterwards. The render thread reads the points with a + // retry loop so it never observes a half-shifted buffer. Audio-thread cursor + // reads ignore this; a momentarily torn read there is harmless and pre-dates + // the display path. + std::atomic Revision{ 0u }; }; // In-memory MIDI loop. Records sample-offset-stamped events relative to the @@ -188,6 +196,17 @@ namespace midi // Clear a lane's mapping and control points (non-audio thread; delete key). void ClearAutomationLane(std::size_t laneIdx) noexcept; + // Render-thread consistent read of a lane's control points via the lane + // seqlock. Copies up to maxPoints (frac, value) pairs into out and returns + // the count actually copied. Returns 0 for an invalid lane. Safe to call + // concurrently with MIDI-thread writes (retries on a torn read). + std::uint16_t SnapshotAutomationLanePoints(std::size_t laneIdx, + std::pair* out, std::size_t maxPoints) const noexcept; + + // Whether a lane currently has a mapping wired. Used by the renderer to gate + // and highlight active automation bands. + bool IsAutomationLaneActive(std::size_t laneIdx) const noexcept; + // Non-destructive start-time quantisation. Non-RT publication builds immutable // event buffers and publishes a raw pointer for audio-thread readers. Retained // buffers are not overwritten or freed until this MidiLoop is destroyed, so From d9cf2a0ae7c9c71d4b6bed9cc52a60d5dde3e065 Mon Sep 17 00:00:00 2001 From: malisimat Date: Fri, 19 Jun 2026 16:29:30 +0100 Subject: [PATCH 09/27] Auto add lane on VST editor change, overwrite existing automation on change --- JammaLib/src/engine/LoopTake.h | 9 + JammaLib/src/engine/Station.cpp | 58 +++- JammaLib/src/engine/Station.h | 11 + JammaLib/src/graphics/MidiModel.cpp | 4 +- JammaLib/src/graphics/VstEditorWindow.h | 2 + JammaLib/src/midi/MidiLoop.cpp | 58 ++++ JammaLib/src/midi/MidiLoop.h | 32 ++ JammaLib/src/midi/MidiRouter.cpp | 308 +++++++++++++++++- JammaLib/src/midi/MidiRouter.h | 82 +++++ JammaLib/src/vst/IVstPlugin.h | 16 +- JammaLib/src/vst/Vst2Plugin.cpp | 5 +- JammaLib/src/vst/VstChain.h | 13 + JammaLib/src/vst/VstEditorWindowManager.cpp | 17 + test/JammaLib_Tests/JammaLib_Tests.vcxproj | 2 + .../engine/StationMidiInstrument_Tests.cpp | 53 ++- .../MidiAutomationLaneResolution_Tests.cpp | 165 ++++++++++ .../VstEditorAutomationSuppression_Tests.cpp | 97 ++++++ 17 files changed, 915 insertions(+), 17 deletions(-) create mode 100644 test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp create mode 100644 test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp diff --git a/JammaLib/src/engine/LoopTake.h b/JammaLib/src/engine/LoopTake.h index 93cb2cc4..49aff855 100644 --- a/JammaLib/src/engine/LoopTake.h +++ b/JammaLib/src/engine/LoopTake.h @@ -156,6 +156,15 @@ namespace engine std::shared_ptr GetVstPlugin(size_t index) const; std::vector VstEntries() const; + // True if plugin is hosted by this take's VST chain. Identity comparison + // only; non-RT (reads the published chain). Used to resolve the owner of + // an editor-driven automation event. + bool OwnsPlugin(const vst::IVstPlugin* plugin) const noexcept + { + auto chain = _vstChain.load(std::memory_order_acquire); + return chain && chain->ContainsPlugin(plugin); + } + void Record(std::vector channels, std::string stationName, std::vector midiChannels = {}, diff --git a/JammaLib/src/engine/Station.cpp b/JammaLib/src/engine/Station.cpp index f1300341..248a0538 100644 --- a/JammaLib/src/engine/Station.cpp +++ b/JammaLib/src/engine/Station.cpp @@ -505,13 +505,60 @@ void Station::RebuildAutomationDispatch() _automationDispatchBack ^= 1u; } +std::shared_ptr Station::_LastRecordedMidiLoop(const std::shared_ptr& take) +{ + std::shared_ptr last; + if (!take) + return last; + + for (const auto& loop : take->GetMidiLoops()) + { + if (loop && loop->LoopLengthSamps() > 0u) + last = loop; + } + return last; +} + +std::shared_ptr Station::ResolveEditorAutomationLoop(const vst::IVstPlugin* plugin) const +{ + if (!plugin) + return nullptr; + + const auto& takes = GetLoopTakes(); + + // Take-level ownership: record into the owning take's last recorded loop. + for (const auto& take : takes) + { + if (take && take->OwnsPlugin(plugin)) + { + if (auto loop = _LastRecordedMidiLoop(take)) + return loop; + } + } + + // Station-level ownership: record into the station's last recorded loop + // (the most recently created MIDI loop across all takes). + auto chain = _vstChain.load(std::memory_order_acquire); + if (chain && chain->ContainsPlugin(plugin)) + { + std::shared_ptr last; + for (const auto& take : takes) + { + if (auto loop = _LastRecordedMidiLoop(take)) + last = loop; + } + return last; + } + + return nullptr; +} + void Station::_RunAutomationDispatch(std::uint32_t blockStartSample) noexcept { auto* dispatches = _automationDispatch.load(std::memory_order_acquire); if (!dispatches) return; - const bool recordHeld = midi::MidiRouter::IsAutomationRecordHeld(); const std::uint8_t frontIdx = (dispatches == _automationDispatchBuf[0]) ? 0u : 1u; const auto count = _automationDispatchCount[frontIdx]; @@ -521,9 +568,12 @@ void Station::_RunAutomationDispatch(std::uint32_t blockStartSample) noexcept if (!entry.plugin || !entry.loop) continue; - // While recording automation, bypass playback writes so incoming CC can own - // parameter movement across all active mappings without tug-of-war. - if (recordHeld) + // Editor-driven recording leaves a short per-parameter cool-down so a just + // dragged parameter is not snapped back to its recorded curve the instant + // automation record is released. Only the matching (plugin, parameter) pair + // is held off; all other automation plays normally. Sample-domain deadline, + // so the audio thread never reads a wall clock. + if (midi::MidiRouter::IsParameterSuppressed(entry.plugin, entry.paramIdx, blockStartSample)) continue; const double frac = (entry.loopLengthSamps > 0u) diff --git a/JammaLib/src/engine/Station.h b/JammaLib/src/engine/Station.h index 93f94003..6f80e057 100644 --- a/JammaLib/src/engine/Station.h +++ b/JammaLib/src/engine/Station.h @@ -187,6 +187,13 @@ namespace engine // wiring changes or a recording is released. void RebuildAutomationDispatch(); + // Resolve the MIDI loop that should host editor-driven automation for a + // plugin hosted by this station (station-level or take-level chain). + // Returns the owner's last recorded MIDI loop, or nullptr when this + // station does not host the plugin or has no recorded MIDI loop yet. + // Non-audio (MIDI pump) thread only. + std::shared_ptr ResolveEditorAutomationLoop(const vst::IVstPlugin* plugin) const; + // Test hook: run one automation dispatch block in isolation (drives // SetParameter on wired plugins). Non-RT; mirrors the audio-thread path. void RunAutomationDispatchForTest(std::uint32_t blockStartSample) noexcept @@ -287,6 +294,10 @@ namespace engine // lane's cursor, interpolate, and SetParameter (delta-gated). Real-time safe. void _RunAutomationDispatch(std::uint32_t blockStartSample) noexcept; + // Last recorded MIDI loop in a take (most recently created loop with a + // non-zero length), or nullptr. Non-audio thread helper. + static std::shared_ptr _LastRecordedMidiLoop(const std::shared_ptr& take); + bool _flipTakeBuffer; bool _flipAudioBuffer; std::string _name; diff --git a/JammaLib/src/graphics/MidiModel.cpp b/JammaLib/src/graphics/MidiModel.cpp index c30a713e..ce5073af 100644 --- a/JammaLib/src/graphics/MidiModel.cpp +++ b/JammaLib/src/graphics/MidiModel.cpp @@ -410,7 +410,7 @@ void MidiModel::_DrawAutomation(GlDrawContext& glCtx) const double rawRadius = 70.0 * std::log(static_cast(lengthSamps)) - 600.0; const float baseRadius = static_cast(std::clamp(rawRadius, 50.0, 400.0)); - const float laneHeight = baseRadius * 0.16f; + const float laneHeight = baseRadius * 0.64f; const bool recording = midi::MidiRouter::IsAutomationRecordHeld(); const float playFrac = static_cast(_loopIndexFrac); @@ -458,7 +458,7 @@ void MidiModel::_DrawAutomation(GlDrawContext& glCtx) flat[i * 2u + 1u] = pts[i].second; } - const float laneRadius = baseRadius * (1.05f + static_cast(lane) * 0.045f); + const float laneRadius = baseRadius * 1.5f * (1.05f + static_cast(lane) * 0.045f); const auto& col = palette[lane % palette.size()]; glUniform2fv(locPoints, count, flat.data()); diff --git a/JammaLib/src/graphics/VstEditorWindow.h b/JammaLib/src/graphics/VstEditorWindow.h index a1839c2f..27248229 100644 --- a/JammaLib/src/graphics/VstEditorWindow.h +++ b/JammaLib/src/graphics/VstEditorWindow.h @@ -60,6 +60,8 @@ namespace graphics void Destroy(); bool IsOpen() const noexcept { return _editorWnd.load() != nullptr; } + const std::shared_ptr& Plugin() const noexcept { return _plugin; } + HWND EditorHwnd() const noexcept { return _editorWnd.load(std::memory_order_acquire); } // Called by the window's WNDPROC for WM_SIZE. void OnAction(const actions::WindowAction& action); diff --git a/JammaLib/src/midi/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index fce69e20..34390de7 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -468,6 +468,64 @@ void MidiLoop::ClearAutomationLane(std::size_t laneIdx) noexcept lane.Revision.store(gen + 2u, std::memory_order_release); } +void MidiLoop::ClearAutomationLanePoints(std::size_t laneIdx) noexcept +{ + if (laneIdx >= MaxAutomationLanes) + return; + + auto& lane = _lanes[laneIdx]; + const auto gen = lane.Revision.load(std::memory_order_relaxed); + lane.Revision.store(gen + 1u, std::memory_order_release); + lane.PointCount = 0u; + lane.Revision.store(gen + 2u, std::memory_order_release); +} + +std::optional MidiLoop::ResolveAutomationLaneFor(const vst::IVstPlugin* plugin, + unsigned int paramIdx) const noexcept +{ + // 1) Reuse an active lane already mapped to this exact (plugin, parameter). + for (std::size_t i = 0u; i < MaxAutomationLanes; ++i) + { + const auto& mapping = _lanes[i].Mapping; + if (mapping.IsActive() + && mapping.TargetPlugin == plugin + && mapping.TargetParameterIndex == paramIdx) + return i; + } + + // 2) Otherwise claim the first inactive lane. + for (std::size_t i = 0u; i < MaxAutomationLanes; ++i) + { + if (!_lanes[i].Mapping.IsActive()) + return i; + } + + // 3) All lanes occupied by other mappings. + return std::nullopt; +} + +bool MidiLoop::WireEditorAutomationLane(std::size_t laneIdx, + vst::IVstPlugin* plugin, + unsigned int paramIdx) noexcept +{ + if (laneIdx >= MaxAutomationLanes) + return false; + + auto& mapping = _lanes[laneIdx].Mapping; + const bool alreadyMapped = mapping.IsActive() + && mapping.TargetPlugin == plugin + && mapping.TargetParameterIndex == paramIdx; + if (alreadyMapped) + return false; + + // Publish the target before activating the match key so a reader that observes + // the active key also observes the resolved plugin/parameter. + mapping.TargetPlugin = plugin; + mapping.TargetParameterIndex = paramIdx; + mapping.MatchKey.store(AutomationMapping::MakeEditorMatchKey(), std::memory_order_release); + return true; +} + std::uint16_t MidiLoop::SnapshotAutomationLanePoints(std::size_t laneIdx, std::pair* out, std::size_t maxPoints) const noexcept { diff --git a/JammaLib/src/midi/MidiLoop.h b/JammaLib/src/midi/MidiLoop.h index dcd08d7f..597c5d16 100644 --- a/JammaLib/src/midi/MidiLoop.h +++ b/JammaLib/src/midi/MidiLoop.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -68,6 +69,15 @@ namespace midi return (1u << 16) | (static_cast(ch) << 8) | static_cast(cc); } + // Match key for a lane wired directly from a plugin editor drag (no CC + // source). Active so it renders and plays back, but uses out-of-range + // channel/CC sentinels (0xFF) that no real incoming CC can ever match, so + // live CC recording never writes into an editor-driven lane. + static constexpr std::uint32_t MakeEditorMatchKey() noexcept + { + return MakeMatchKey(0xFFu, 0xFFu); + } + bool IsActive() const noexcept { return ((MatchKey.load(std::memory_order_relaxed) >> 16) & 1u) != 0u; @@ -196,6 +206,28 @@ namespace midi // Clear a lane's mapping and control points (non-audio thread; delete key). void ClearAutomationLane(std::size_t laneIdx) noexcept; + // Clear only a lane's recorded points while preserving mapping metadata. + // Used by editor-driven overwrite mode to replace an existing curve from the + // first drag event in a new automation-record gesture. + void ClearAutomationLanePoints(std::size_t laneIdx) noexcept; + + // Resolve which lane should host editor-driven automation for the given + // (plugin, parameter) pair. Resolution rule: first an active lane already + // mapped to that pair (reuse), otherwise the first inactive lane (claim), + // otherwise std::nullopt (full). Pure query: never mutates a lane. Called on + // the non-audio (MIDI pump) thread. + std::optional ResolveAutomationLaneFor(const vst::IVstPlugin* plugin, + unsigned int paramIdx) const noexcept; + + // Wire a lane for editor-driven automation (no CC source). Sets the target + // plugin/parameter and an editor match key. Returns true when the mapping + // topology actually changed (so the caller can rebuild the audio dispatch), + // false when the lane was already mapped to the same (plugin, parameter). + // Non-audio thread only. + bool WireEditorAutomationLane(std::size_t laneIdx, + vst::IVstPlugin* plugin, + unsigned int paramIdx) noexcept; + // Render-thread consistent read of a lane's control points via the lane // seqlock. Copies up to maxPoints (frac, value) pairs into out and returns // the count actually copied. Returns 0 for an invalid lane. Safe to call diff --git a/JammaLib/src/midi/MidiRouter.cpp b/JammaLib/src/midi/MidiRouter.cpp index 2553b662..f8ae957b 100644 --- a/JammaLib/src/midi/MidiRouter.cpp +++ b/JammaLib/src/midi/MidiRouter.cpp @@ -16,6 +16,8 @@ using namespace midi; namespace midi { std::atomic MidiRouter::_automationRecordHeld{ false }; + std::array MidiRouter::_automationSuppressions{}; + std::atomic MidiRouter::_automationSuppressionCount{ 0u }; } std::pair, std::shared_ptr> MidiRouter::_ResolveAutomationTarget( @@ -69,6 +71,7 @@ actions::ActionResult MidiRouter::HandleAutomationKey(const actions::KeyAction& if (isDown && ctrlShift && !_automationRecordKeyHeld) { _automationRecordKeyHeld = true; + _ResetEditorOverwriteSessions(); _automationRecordHeld.store(true, std::memory_order_release); std::cout << ">> Automation record armed (Ctrl+Shift+A) <<" << std::endl; return eaten; @@ -187,6 +190,178 @@ void MidiRouter::SetAutomationRecordHeldForTest(bool held) noexcept _automationRecordHeld.store(held, std::memory_order_release); } +void MidiRouter::_ResetEditorOverwriteSessions() noexcept +{ + for (auto& session : _editorOverwriteSessions) + session.Active = false; +} + +void MidiRouter::_RecordOverwritePoint( + std::array, MaxEditorOverwritePoints>& points, + std::size_t& count, + float frac, + float value) noexcept +{ + constexpr float fracEpsilon = 1.0f / 2048.0f; + + std::size_t insertAt = 0u; + while (insertAt < count && points[insertAt].first < frac) + ++insertAt; + + if (insertAt < count && (points[insertAt].first - frac) <= fracEpsilon) + { + points[insertAt].second = value; + return; + } + + if (insertAt > 0u && (frac - points[insertAt - 1u].first) <= fracEpsilon) + { + points[insertAt - 1u].second = value; + return; + } + + if (count >= MaxEditorOverwritePoints) + return; + + for (std::size_t i = count; i > insertAt; --i) + points[i] = points[i - 1u]; + points[insertAt] = std::make_pair(frac, value); + ++count; +} + +void MidiRouter::_ApplyEditorOverwriteSessions(std::uint32_t nowSample) noexcept +{ + for (auto& session : _editorOverwriteSessions) + { + if (!session.Active) + continue; + + if (static_cast(session.ExpirySample - nowSample) <= 0) + { + session.Active = false; + continue; + } + + auto loop = session.Loop.lock(); + if (!loop) + { + session.Active = false; + continue; + } + + if (session.LaneIdx >= MidiLoop::MaxAutomationLanes) + { + session.Active = false; + continue; + } + + const auto& mapping = loop->GetLane(session.LaneIdx).Mapping; + if (!mapping.IsActive() + || mapping.TargetPlugin != session.Plugin + || mapping.TargetParameterIndex != session.ParamIndex) + { + session.Active = false; + continue; + } + + loop->ClearAutomationLanePoints(session.LaneIdx); + for (std::size_t i = 0u; i < session.PointCount; ++i) + { + const auto& pt = session.Points[i]; + loop->SetAutomationValueAtFrac(session.LaneIdx, pt.first, pt.second); + } + } +} + +bool MidiRouter::IsParameterSuppressed(const vst::IVstPlugin* plugin, + unsigned int paramIdx, + std::uint32_t blockStartSample) noexcept +{ + if (!plugin) + return false; + + const auto count = _automationSuppressionCount.load(std::memory_order_acquire); + for (std::uint8_t i = 0u; i < count; ++i) + { + const auto& slot = _automationSuppressions[i]; + if (slot.Plugin.load(std::memory_order_relaxed) != plugin) + continue; + if (slot.ParamIndex.load(std::memory_order_relaxed) != paramIdx) + continue; + + const auto expiry = slot.ExpirySample.load(std::memory_order_relaxed); + // Signed sample-domain comparison tolerates uint32 wraparound. + if (static_cast(expiry - blockStartSample) > 0) + return true; + } + return false; +} + +void MidiRouter::RefreshAutomationSuppression(const vst::IVstPlugin* plugin, + unsigned int paramIdx, + std::uint32_t nowSample, + std::uint32_t expirySample) noexcept +{ + if (!plugin) + return; + + const auto count = _automationSuppressionCount.load(std::memory_order_relaxed); + + // 1) Refresh an existing entry for this exact (plugin, parameter). + for (std::uint8_t i = 0u; i < count; ++i) + { + auto& slot = _automationSuppressions[i]; + if (slot.Plugin.load(std::memory_order_relaxed) == plugin + && slot.ParamIndex.load(std::memory_order_relaxed) == paramIdx) + { + slot.ExpirySample.store(expirySample, std::memory_order_release); + return; + } + } + + // 2) Reclaim an already-expired slot. + for (std::uint8_t i = 0u; i < count; ++i) + { + auto& slot = _automationSuppressions[i]; + const auto expiry = slot.ExpirySample.load(std::memory_order_relaxed); + if (static_cast(expiry - nowSample) <= 0) + { + slot.Plugin.store(plugin, std::memory_order_relaxed); + slot.ParamIndex.store(paramIdx, std::memory_order_relaxed); + slot.ExpirySample.store(expirySample, std::memory_order_release); + return; + } + } + + // 3) Claim a fresh slot, publishing the fields before bumping the count. + if (count < MaxAutomationSuppressions) + { + auto& slot = _automationSuppressions[count]; + slot.Plugin.store(plugin, std::memory_order_relaxed); + slot.ParamIndex.store(paramIdx, std::memory_order_relaxed); + slot.ExpirySample.store(expirySample, std::memory_order_release); + _automationSuppressionCount.store(static_cast(count + 1u), std::memory_order_release); + return; + } + + // 4) Table full (16 distinct live parameters): overwrite the first slot. + auto& slot = _automationSuppressions[0]; + slot.Plugin.store(plugin, std::memory_order_relaxed); + slot.ParamIndex.store(paramIdx, std::memory_order_relaxed); + slot.ExpirySample.store(expirySample, std::memory_order_release); +} + +void MidiRouter::ResetAutomationSuppressionForTest() noexcept +{ + for (auto& slot : _automationSuppressions) + { + slot.Plugin.store(nullptr, std::memory_order_relaxed); + slot.ParamIndex.store(0u, std::memory_order_relaxed); + slot.ExpirySample.store(0u, std::memory_order_relaxed); + } + _automationSuppressionCount.store(0u, std::memory_order_release); +} + void MidiRouter::InitMidi(const io::UserConfig& cfg, const base::LoggingConfig& loggingConfig, std::atomic& audioSampleCounter, @@ -474,6 +649,131 @@ MidiRouter::TriggerDispatchSummary MidiRouter::DispatchMidiTriggerEventForTest(s return _DispatchMidiTriggerEvent(deviceSlot, event, userConfig, audioParams); } +void MidiRouter::_ConsumeEditorAutomation(const std::vector>& stations, + std::uint64_t globalSampleNow, + const audio::AudioStreamParams& audioParams) noexcept +{ + const auto seq = vst::_lastTouchedParam.Sequence.load(std::memory_order_acquire); + if (seq == _lastEditorAutomationSeq) + return; + _lastEditorAutomationSeq = seq; + + // Only fold editor drags into automation while the record gesture is held. + if (!_automationRecordHeld.load(std::memory_order_acquire)) + return; + + auto* plugin = vst::_lastTouchedParam.Plugin.load(std::memory_order_acquire); + if (!plugin) + return; + const auto paramIdx = vst::_lastTouchedParam.ParameterIndex.load(std::memory_order_acquire); + const auto value = vst::_lastTouchedParam.Value.load(std::memory_order_acquire); + + // Find the first non-remote station whose recording loop owns this plugin. + std::shared_ptr targetLoop; + for (const auto& station : stations) + { + if (!station || station->IsRemote()) + continue; + if (auto loop = station->ResolveEditorAutomationLoop(plugin)) + { + targetLoop = loop; + break; + } + } + + if (!targetLoop) + { + std::cout << "[Automation] editor drag ignored: no recording loop owns plugin " + << static_cast(plugin) << " (param " << paramIdx << ")\n"; + return; + } + + const auto loopLen = targetLoop->LoopLengthSamps(); + if (loopLen == 0u) + return; + + const auto laneOpt = targetLoop->ResolveAutomationLaneFor(plugin, paramIdx); + if (!laneOpt) + { + std::cout << "[Automation] editor drag ignored: no free automation lane for plugin " + << static_cast(plugin) << " (param " << paramIdx << ")\n"; + return; + } + + const auto laneIdx = *laneOpt; + + const unsigned int sampleRate = (audioParams.SampleRate > 0u) ? audioParams.SampleRate : 48000u; + const auto cooldownSamples = static_cast( + (AutomationSuppressionCooldownMs * static_cast(sampleRate)) / 1000.0); + const auto nowSample = static_cast(globalSampleNow); + const auto expirySample = nowSample + cooldownSamples; + + EditorOverwriteSession* targetSession = nullptr; + for (auto& session : _editorOverwriteSessions) + { + if (session.Active && session.Plugin == plugin && session.ParamIndex == paramIdx) + { + targetSession = &session; + break; + } + } + + if (!targetSession) + { + for (auto& session : _editorOverwriteSessions) + { + if (!session.Active + || static_cast(session.ExpirySample - nowSample) <= 0) + { + targetSession = &session; + break; + } + } + } + + if (!targetSession) + { + std::cout << "[Automation] editor drag ignored: no free overwrite session for plugin " + << static_cast(plugin) << " (param " << paramIdx << ")\n"; + return; + } + + if (!targetSession->Active) + { + targetSession->Active = true; + targetSession->Plugin = plugin; + targetSession->ParamIndex = paramIdx; + targetSession->PointCount = 0u; + } + else + { + auto existingLoop = targetSession->Loop.lock(); + if (!existingLoop || existingLoop.get() != targetLoop.get() || targetSession->LaneIdx != laneIdx) + targetSession->PointCount = 0u; + } + + targetSession->Loop = targetLoop; + targetSession->LaneIdx = laneIdx; + targetSession->ExpirySample = expirySample; + + if (targetLoop->WireEditorAutomationLane(laneIdx, plugin, paramIdx)) + { + std::cout << "[Automation] editor drag wired lane " << laneIdx + << " -> plugin " << static_cast(plugin) + << " param " << paramIdx << "\n"; + } + + const float frac = (loopLen > 0u) + ? static_cast(static_cast(globalSampleNow % loopLen) + / static_cast(loopLen)) + : 0.0f; + _RecordOverwritePoint(targetSession->Points, targetSession->PointCount, frac, value); + + // Refresh playback suppression so the recorded curve doesn't snap the just + // dragged parameter back during the post-release cool-down window. + RefreshAutomationSuppression(plugin, paramIdx, nowSample, expirySample); +} + MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector>& stations, std::uint64_t globalSampleNow, const io::UserConfig& userConfig, @@ -483,8 +783,11 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector(globalSampleNow)); return summary; - + } for (const auto& input : *midiInputs) { if (!input) @@ -582,6 +885,9 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector(globalSampleNow)); + return summary; } diff --git a/JammaLib/src/midi/MidiRouter.h b/JammaLib/src/midi/MidiRouter.h index 6ecde75e..cb200372 100644 --- a/JammaLib/src/midi/MidiRouter.h +++ b/JammaLib/src/midi/MidiRouter.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -22,6 +24,11 @@ namespace io struct UserConfig; } +namespace vst +{ + class IVstPlugin; +} + namespace engine { class LoopTake; @@ -90,7 +97,36 @@ namespace midi static bool IsAutomationRecordHeld() noexcept; static void SetAutomationRecordHeldForTest(bool held) noexcept; + // --- Editor-driven automation feedback suppression --- + // Per (plugin, parameter) cool-down published by the non-RT MIDI pump and + // read by the audio-thread automation player. While suppressed, recorded + // automation playback skips that one parameter so a live editor drag is not + // fought by its own freshly recorded curve. Deadlines live in the audio + // sample domain so the audio thread never reads a wall clock. + + // Cool-down window after the last editor-origin change, in milliseconds. + static constexpr double AutomationSuppressionCooldownMs = 800.0; + + // Audio thread: true while (plugin, paramIdx) is within its cool-down at + // blockStartSample. Real-time safe: bounded flat scan, no locks/allocation, + // no wall-clock read. + static bool IsParameterSuppressed(const vst::IVstPlugin* plugin, + unsigned int paramIdx, + std::uint32_t blockStartSample) noexcept; + + // Non-RT: refresh (or claim) the suppression entry for (plugin, paramIdx) + // with an absolute sample-domain expiry. nowSample lets stale entries be + // reclaimed. + static void RefreshAutomationSuppression(const vst::IVstPlugin* plugin, + unsigned int paramIdx, + std::uint32_t nowSample, + std::uint32_t expirySample) noexcept; + + // Test hook: drop all suppression entries. + static void ResetAutomationSuppressionForTest() noexcept; + private: + static constexpr std::size_t MaxEditorOverwritePoints = 256u; static constexpr std::uint8_t UnresolvedMidiDeviceSlot = 0xffu; struct MidiInputEndpoint @@ -119,6 +155,21 @@ namespace midi const std::shared_ptr& hoveredTake) const; void _PublishMidiTriggerRoutes(); + // Non-RT: poll vst::_lastTouchedParam for a fresh editor-origin parameter + // change and, while automation record is held, record it into the owning + // station's last recorded MIDI loop (auto-creating/reusing a lane) and + // refresh that parameter's playback suppression. Called once per pump. + void _ConsumeEditorAutomation(const std::vector>& stations, + std::uint64_t globalSampleNow, + const audio::AudioStreamParams& audioParams) noexcept; + void _ResetEditorOverwriteSessions() noexcept; + void _RecordOverwritePoint( + std::array, MaxEditorOverwritePoints>& points, + std::size_t& count, + float frac, + float value) noexcept; + void _ApplyEditorOverwriteSessions(std::uint32_t nowSample) noexcept; + std::atomic>>> _midiInputs; std::vector _midiTriggerRoutes; std::atomic>> _midiTriggerRoutesSnapshot; @@ -132,5 +183,36 @@ namespace midi std::atomic _selectedLaneIndex{ 0u }; bool _automationRecordKeyHeld = false; static std::atomic _automationRecordHeld; + + // Highest editor-origin sequence already consumed by _ConsumeEditorAutomation. + // Touched only on the (single) MIDI pump thread. + std::uint64_t _lastEditorAutomationSeq = 0u; + + struct EditorOverwriteSession + { + bool Active = false; + const vst::IVstPlugin* Plugin = nullptr; + unsigned int ParamIndex = 0u; + std::weak_ptr Loop; + std::size_t LaneIdx = 0u; + std::array, MaxEditorOverwritePoints> Points{}; + std::size_t PointCount = 0u; + std::uint32_t ExpirySample = 0u; + }; + static constexpr std::size_t MaxEditorOverwriteSessions = 16u; + std::array _editorOverwriteSessions{}; + + // Fixed-capacity per-parameter suppression table. Written on the non-RT MIDI + // pump thread, read on the audio thread; each field is independently atomic + // and a momentarily stale read is harmless (best-effort cool-down). + struct AutomationSuppressionSlot + { + std::atomic Plugin{ nullptr }; + std::atomic ParamIndex{ 0u }; + std::atomic ExpirySample{ 0u }; + }; + static constexpr std::size_t MaxAutomationSuppressions = 16u; + static std::array _automationSuppressions; + static std::atomic _automationSuppressionCount; }; } \ No newline at end of file diff --git a/JammaLib/src/vst/IVstPlugin.h b/JammaLib/src/vst/IVstPlugin.h index 43c30f9f..d2ee2bce 100644 --- a/JammaLib/src/vst/IVstPlugin.h +++ b/JammaLib/src/vst/IVstPlugin.h @@ -122,19 +122,25 @@ namespace vst }; // Records the most recently host-automated plugin parameter so the UI thread - // can wire it to a MIDI automation lane ("MIDI learn" target half). + // can wire it to a MIDI automation lane ("MIDI learn" target half), and so the + // non-RT MIDI pump can record live editor-driven automation while automation + // record mode is held. // // Threading: written on the audio/UI thread inside Vst2Plugin's // audioMasterAutomate host callback when the user touches a parameter in a - // plugin editor; read on the non-audio (UI/action) thread when the user - // presses the wire key. Each field is independently atomic; readers must - // tolerate a brief torn triple, which is acceptable because wiring only - // happens after the user deliberately stops touching the control. + // plugin editor; read on the non-audio (UI/action/job) thread when the user + // presses the wire key or when the MIDI pump consumes editor-origin events. + // Each field is independently atomic; readers must tolerate a brief torn + // triple, which is acceptable because wiring only happens after the user + // deliberately stops touching the control. Sequence is bumped (release) after + // the triple is stored so a consumer that acquire-loads Sequence observes a + // coherent Plugin/ParameterIndex/Value and can detect fresh events by change. struct LastTouchedParameter { std::atomic Plugin{ nullptr }; std::atomic ParameterIndex{ 0u }; std::atomic Value{ 0.0f }; + std::atomic Sequence{ 0u }; }; extern LastTouchedParameter _lastTouchedParam; diff --git a/JammaLib/src/vst/Vst2Plugin.cpp b/JammaLib/src/vst/Vst2Plugin.cpp index ac371ffc..5f351260 100644 --- a/JammaLib/src/vst/Vst2Plugin.cpp +++ b/JammaLib/src/vst/Vst2Plugin.cpp @@ -564,12 +564,15 @@ VstIntPtr __cdecl Vst2Plugin::HostCallback(AEffect* effect, return 0; case audioMasterAutomate: // Parameter automation notification: record the most recently touched - // parameter so the UI thread can wire it to a MIDI automation lane. + // parameter so the UI thread can wire it to a MIDI automation lane and the + // MIDI pump can record live editor-driven automation. Publish the triple + // first, then bump Sequence (release) so consumers see a coherent event. if (self) { _lastTouchedParam.Plugin.store(self, std::memory_order_relaxed); _lastTouchedParam.ParameterIndex.store(static_cast(index), std::memory_order_relaxed); _lastTouchedParam.Value.store(opt, std::memory_order_relaxed); + _lastTouchedParam.Sequence.fetch_add(1u, std::memory_order_release); } return 0; case audioMasterIdle: diff --git a/JammaLib/src/vst/VstChain.h b/JammaLib/src/vst/VstChain.h index 4640bf77..bc62d693 100644 --- a/JammaLib/src/vst/VstChain.h +++ b/JammaLib/src/vst/VstChain.h @@ -44,6 +44,19 @@ namespace vst size_t NumPlugins() const noexcept { return _plugins.size(); } + // Returns true if plugin is one of the instances held by this chain. + // Identity comparison only (never dereferences). Not RT-safe (walks the + // plugin vector); call from a non-RT thread only. + bool ContainsPlugin(const IVstPlugin* plugin) const noexcept + { + if (!plugin) + return false; + for (const auto& p : _plugins) + if (p.get() == plugin) + return true; + return false; + } + // Returns true if there is at least one loaded, non-bypassed plugin. // Real-time safe. bool IsActive() const noexcept; diff --git a/JammaLib/src/vst/VstEditorWindowManager.cpp b/JammaLib/src/vst/VstEditorWindowManager.cpp index 5e7199c8..693a98b3 100644 --- a/JammaLib/src/vst/VstEditorWindowManager.cpp +++ b/JammaLib/src/vst/VstEditorWindowManager.cpp @@ -131,6 +131,23 @@ namespace vst if (!plugin || !plugin->IsLoaded()) return false; + for (const auto& window : _vstEditorWindows) + { + if (!window || !window->IsOpen()) + continue; + + if (window->Plugin().get() == plugin.get()) + { + if (const auto hwnd = window->EditorHwnd()) + { + if (IsIconic(hwnd)) + ShowWindow(hwnd, SW_RESTORE); + SetForegroundWindow(hwnd); + } + return true; + } + } + auto window = std::make_unique(); const auto hInstance = GetModuleHandle(nullptr); if (!window->Create(hInstance, plugin)) diff --git a/test/JammaLib_Tests/JammaLib_Tests.vcxproj b/test/JammaLib_Tests/JammaLib_Tests.vcxproj index d92288b6..3a172cf2 100644 --- a/test/JammaLib_Tests/JammaLib_Tests.vcxproj +++ b/test/JammaLib_Tests/JammaLib_Tests.vcxproj @@ -187,6 +187,8 @@ xcopy /y "$(VcpkgInstalledDir)$(VcpkgTriplet)\debug\bin\gtest_main.dll" "$(OutDi + + diff --git a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp index c42b2a87..b65a51d7 100644 --- a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp +++ b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp @@ -579,7 +579,7 @@ TEST(StationAutomation, WiredLaneDrivesPluginSetParameterDuringPlayback) EXPECT_NEAR(0.75f, plugin->LastParamValue, 1.0e-2f); } -TEST(StationAutomation, RecordingTargetLoopSuppressesPlaybackForThatLoop) +TEST(StationAutomation, RecordHeldDoesNotSuppressPlaybackWithoutParameterCooldown) { auto station = MakeStation("station-automation-record"); auto plugin = AddPlugin(station, L"fake-automation-record.dll"); @@ -603,15 +603,60 @@ TEST(StationAutomation, RecordingTargetLoopSuppressesPlaybackForThatLoop) std::memory_order_relaxed); station->RebuildAutomationDispatch(); - // While automation recording is active, playback writes must be suppressed. + // Holding automation record alone must not mute playback globally. midi::MidiRouter::SetAutomationRecordHeldForTest(true); station->RunAutomationDispatchForTest(0u); - EXPECT_EQ(0u, plugin->ParamSetCalls); + EXPECT_GE(plugin->ParamSetCalls, 1u); + EXPECT_EQ(1u, plugin->LastParamIndex); + EXPECT_NEAR(0.4f, plugin->LastParamValue, 1.0e-4f); - // Releasing record resumes playback. + // Releasing record keeps normal playback behavior. midi::MidiRouter::SetAutomationRecordHeldForTest(false); station->RunAutomationDispatchForTest(0u); EXPECT_GE(plugin->ParamSetCalls, 1u); EXPECT_EQ(1u, plugin->LastParamIndex); EXPECT_NEAR(0.4f, plugin->LastParamValue, 1.0e-4f); } + +TEST(StationAutomation, EditorSuppressionGatesPlaybackUntilCooldownExpires) +{ + midi::MidiRouter::ResetAutomationSuppressionForTest(); + midi::MidiRouter::SetAutomationRecordHeldForTest(false); + + auto station = MakeStation("station-automation-suppress"); + auto plugin = AddPlugin(station, L"fake-automation-suppress.dll"); + auto take = MakeMidiTake("automation-suppress-take"); + station->AddTake(take); + station->CommitChanges(); + + take->Record({}, station->Name(), { 0u }, { "Keys" }); + take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); + take->Play(0u, 128u, 0u); + + auto midiLoop = take->GetMidiLoops()[0]; + ASSERT_NE(nullptr, midiLoop); + midiLoop->SetAutomationValueAtFrac(0u, 0.0, 0.6f); + + auto& lane = midiLoop->GetLane(0u); + lane.Mapping.TargetPlugin = plugin.get(); + lane.Mapping.TargetParameterIndex = 2u; + lane.Mapping.MatchKey.store( + midi::AutomationMapping::MakeEditorMatchKey(), + std::memory_order_relaxed); + station->RebuildAutomationDispatch(); + + // A live editor drag has just published a cool-down for (plugin, param 2) + // expiring at sample 5000. Record is NOT held, but playback for that one + // parameter must still be held off so the recorded curve doesn't fight the drag. + midi::MidiRouter::RefreshAutomationSuppression(plugin.get(), 2u, /*now*/ 0u, /*expiry*/ 5000u); + + station->RunAutomationDispatchForTest(0u); + EXPECT_EQ(0u, plugin->ParamSetCalls); + + // Past the cool-down deadline, playback resumes normally. + station->RunAutomationDispatchForTest(6000u); + EXPECT_GE(plugin->ParamSetCalls, 1u); + EXPECT_EQ(2u, plugin->LastParamIndex); + + midi::MidiRouter::ResetAutomationSuppressionForTest(); +} diff --git a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp new file mode 100644 index 00000000..b0d80a98 --- /dev/null +++ b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp @@ -0,0 +1,165 @@ +#include "gtest/gtest.h" + +#include "midi/MidiLoop.h" + +using midi::AutomationMapping; +using midi::MidiLoop; + +namespace +{ + // Editor automation only ever compares plugin pointer identity, never + // dereferences it. Use opaque non-null sentinels so the tests stay free of any + // real plugin construction. + vst::IVstPlugin* FakePlugin(std::uintptr_t id) noexcept + { + return reinterpret_cast(id); + } +} + +TEST(MidiAutomationLaneResolution, ClaimsFirstInactiveLaneWhenUnmapped) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x100u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 3u); + ASSERT_TRUE(lane.has_value()); + EXPECT_EQ(0u, *lane); + + // Pure query: nothing should have been activated by resolving. + EXPECT_FALSE(loop.GetLane(0u).Mapping.IsActive()); +} + +TEST(MidiAutomationLaneResolution, ReusesActiveLaneForSamePluginAndParam) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x200u); + + const auto first = loop.ResolveAutomationLaneFor(plugin, 7u); + ASSERT_TRUE(first.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*first, plugin, 7u)); + + // Same (plugin, param) must resolve back to the very same lane. + const auto again = loop.ResolveAutomationLaneFor(plugin, 7u); + ASSERT_TRUE(again.has_value()); + EXPECT_EQ(*first, *again); +} + +TEST(MidiAutomationLaneResolution, IgnoresActiveLaneWithDifferentMapping) +{ + MidiLoop loop; + auto* pluginA = FakePlugin(0x300u); + auto* pluginB = FakePlugin(0x301u); + + const auto laneA = loop.ResolveAutomationLaneFor(pluginA, 1u); + ASSERT_TRUE(laneA.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*laneA, pluginA, 1u)); + + // A different plugin/param must not reuse lane A; it claims the next inactive. + const auto laneB = loop.ResolveAutomationLaneFor(pluginB, 1u); + ASSERT_TRUE(laneB.has_value()); + EXPECT_NE(*laneA, *laneB); +} + +TEST(MidiAutomationLaneResolution, ReturnsNulloptWhenAllLanesOccupied) +{ + MidiLoop loop; + + // Fill every lane with a distinct active mapping. + for (std::size_t i = 0u; i < MidiLoop::MaxAutomationLanes; ++i) + { + auto* plugin = FakePlugin(0x400u + i); + const auto lane = loop.ResolveAutomationLaneFor(plugin, static_cast(i)); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, static_cast(i))); + } + + // A brand new mapping has nowhere to go. + auto* overflow = FakePlugin(0x500u); + const auto none = loop.ResolveAutomationLaneFor(overflow, 99u); + EXPECT_FALSE(none.has_value()); +} + +TEST(MidiAutomationLaneResolution, WireUsesEditorSentinelMatchKey) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x600u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 5u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 5u)); + + auto& mapping = loop.GetLane(*lane).Mapping; + EXPECT_TRUE(mapping.IsActive()); + EXPECT_EQ(plugin, mapping.TargetPlugin); + EXPECT_EQ(5u, mapping.TargetParameterIndex); + EXPECT_EQ(AutomationMapping::MakeEditorMatchKey(), + mapping.MatchKey.load(std::memory_order_relaxed)); + + // Sentinel channel/CC are 0xFF, which no real incoming CC can match. + EXPECT_EQ(0xFFu, mapping.GetChannel()); + EXPECT_EQ(0xFFu, mapping.GetCC()); +} + +TEST(MidiAutomationLaneResolution, RewiringSameMappingIsIdempotent) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x700u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 2u); + ASSERT_TRUE(lane.has_value()); + + // First wire reports a topology change; rewiring the identical mapping does not. + EXPECT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 2u)); + EXPECT_FALSE(loop.WireEditorAutomationLane(*lane, plugin, 2u)); +} + +TEST(MidiAutomationLaneResolution, ClearPointsPreservesLaneMapping) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x710u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 9u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 9u)); + + loop.SetAutomationValueAtFrac(*lane, 0.25, 0.2f); + loop.SetAutomationValueAtFrac(*lane, 0.75, 0.8f); + + std::array, midi::AutomationLane::MaxPoints> points{}; + ASSERT_GT(loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()), 0u); + + loop.ClearAutomationLanePoints(*lane); + + EXPECT_TRUE(loop.GetLane(*lane).Mapping.IsActive()); + EXPECT_EQ(plugin, loop.GetLane(*lane).Mapping.TargetPlugin); + EXPECT_EQ(9u, loop.GetLane(*lane).Mapping.TargetParameterIndex); + EXPECT_EQ(0u, loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size())); +} + +TEST(MidiAutomationLaneResolution, ClearThenReplayFullyReplacesPriorCurve) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x720u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 10u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 10u)); + + // Existing recorded curve. + loop.SetAutomationValueAtFrac(*lane, 0.10, 0.1f); + loop.SetAutomationValueAtFrac(*lane, 0.40, 0.4f); + loop.SetAutomationValueAtFrac(*lane, 0.90, 0.9f); + + // Editor overwrite session rebuilds from only its newly captured points. + loop.ClearAutomationLanePoints(*lane); + loop.SetAutomationValueAtFrac(*lane, 0.20, 0.2f); + loop.SetAutomationValueAtFrac(*lane, 0.30, 0.3f); + + std::array, midi::AutomationLane::MaxPoints> points{}; + const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + ASSERT_EQ(2u, count); + EXPECT_NEAR(0.20f, points[0].first, 1.0e-6f); + EXPECT_NEAR(0.2f, points[0].second, 1.0e-6f); + EXPECT_NEAR(0.30f, points[1].first, 1.0e-6f); + EXPECT_NEAR(0.3f, points[1].second, 1.0e-6f); +} diff --git a/test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp b/test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp new file mode 100644 index 00000000..40c9839b --- /dev/null +++ b/test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp @@ -0,0 +1,97 @@ +#include "gtest/gtest.h" + +#include "midi/MidiRouter.h" +#include "vst/IVstPlugin.h" + +using midi::MidiRouter; + +namespace +{ + // Suppression compares plugin pointer identity only; never dereferenced. + const vst::IVstPlugin* FakePlugin(std::uintptr_t id) noexcept + { + return reinterpret_cast(id); + } + + class SuppressionTestFixture : public ::testing::Test + { + protected: + void SetUp() override { MidiRouter::ResetAutomationSuppressionForTest(); } + void TearDown() override { MidiRouter::ResetAutomationSuppressionForTest(); } + }; +} + +TEST_F(SuppressionTestFixture, UnknownParameterIsNotSuppressed) +{ + auto* plugin = FakePlugin(0x100u); + EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 0u, 0u)); +} + +TEST_F(SuppressionTestFixture, FreshEntrySuppressesUntilExpiry) +{ + auto* plugin = FakePlugin(0x110u); + MidiRouter::RefreshAutomationSuppression(plugin, 4u, /*now*/ 1000u, /*expiry*/ 2000u); + + EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 4u, 1500u)); + EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 4u, 1999u)); +} + +TEST_F(SuppressionTestFixture, ExpiredEntryNoLongerSuppresses) +{ + auto* plugin = FakePlugin(0x120u); + MidiRouter::RefreshAutomationSuppression(plugin, 1u, /*now*/ 1000u, /*expiry*/ 2000u); + + EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 1u, 2000u)); + EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 1u, 2500u)); +} + +TEST_F(SuppressionTestFixture, DifferentParameterIsIndependent) +{ + auto* plugin = FakePlugin(0x130u); + MidiRouter::RefreshAutomationSuppression(plugin, 2u, /*now*/ 0u, /*expiry*/ 5000u); + + EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 2u, 1000u)); + EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 3u, 1000u)); +} + +TEST_F(SuppressionTestFixture, DifferentPluginIsIndependent) +{ + auto* pluginA = FakePlugin(0x140u); + auto* pluginB = FakePlugin(0x141u); + MidiRouter::RefreshAutomationSuppression(pluginA, 0u, /*now*/ 0u, /*expiry*/ 5000u); + + EXPECT_TRUE(MidiRouter::IsParameterSuppressed(pluginA, 0u, 1000u)); + EXPECT_FALSE(MidiRouter::IsParameterSuppressed(pluginB, 0u, 1000u)); +} + +TEST_F(SuppressionTestFixture, RefreshExtendsExistingEntry) +{ + auto* plugin = FakePlugin(0x150u); + MidiRouter::RefreshAutomationSuppression(plugin, 6u, /*now*/ 0u, /*expiry*/ 1000u); + EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 6u, 1000u)); + + // Extending the same (plugin, param) pushes the deadline out without + // consuming a second slot. + MidiRouter::RefreshAutomationSuppression(plugin, 6u, /*now*/ 900u, /*expiry*/ 3000u); + EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 6u, 2000u)); +} + +TEST_F(SuppressionTestFixture, ExpiredSlotIsReclaimedForNewParameter) +{ + // First parameter expires... + auto* plugin = FakePlugin(0x160u); + MidiRouter::RefreshAutomationSuppression(plugin, 0u, /*now*/ 0u, /*expiry*/ 1000u); + + // ...then a new parameter is registered after that deadline; it should reuse + // the freed slot and suppress correctly. + MidiRouter::RefreshAutomationSuppression(plugin, 1u, /*now*/ 2000u, /*expiry*/ 4000u); + + EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 0u, 2000u)); + EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 1u, 3000u)); +} + +TEST_F(SuppressionTestFixture, NullPluginIsNeverSuppressed) +{ + MidiRouter::RefreshAutomationSuppression(nullptr, 0u, 0u, 5000u); + EXPECT_FALSE(MidiRouter::IsParameterSuppressed(nullptr, 0u, 1000u)); +} From 46c775721bf111126e2d5a24c1bf48f0086c48c8 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sat, 20 Jun 2026 18:35:23 +0100 Subject: [PATCH 10/27] Fx automation issues based on thorough review --- JammaLib/src/engine/LoopTake.cpp | 9 +- JammaLib/src/engine/Station.cpp | 15 +- JammaLib/src/engine/Station.h | 9 +- JammaLib/src/midi/MidiLoop.cpp | 140 ++++++-- JammaLib/src/midi/MidiLoop.h | 20 +- JammaLib/src/midi/MidiRouter.cpp | 310 ++++++++---------- JammaLib/src/midi/MidiRouter.h | 28 +- JammaLib/src/vst/Vst2Plugin.cpp | 4 +- JammaLib/src/vst/Vst2Plugin.h | 10 + doc/automation-agent-brief.md | 218 ++++++++++++ doc/automation-agent-issues.md | 47 +++ doc/vst-editor-automation-handoff.md | 200 +++++++++++ .../engine/StationMidiInstrument_Tests.cpp | 65 +++- .../MidiAutomationLaneResolution_Tests.cpp | 175 ++++++++++ .../src/vst/Vst2Plugin_Tests.cpp | 24 ++ 15 files changed, 1034 insertions(+), 240 deletions(-) create mode 100644 doc/automation-agent-brief.md create mode 100644 doc/automation-agent-issues.md create mode 100644 doc/vst-editor-automation-handoff.md diff --git a/JammaLib/src/engine/LoopTake.cpp b/JammaLib/src/engine/LoopTake.cpp index d5ad7cdd..9fb9a85f 100644 --- a/JammaLib/src/engine/LoopTake.cpp +++ b/JammaLib/src/engine/LoopTake.cpp @@ -1338,7 +1338,14 @@ void LoopTake::Play(unsigned long index, { if (midiLoop->State() == midi::MidiLoopState::Recording) { - midiLoop->EndRecord(midiLoopLength); + // Compute the global sample that corresponds to loop-relative position 0 so + // automation frac calculations stay in phase with _midiVisualPlayIndex. + // _midiVisualPlayIndex was just set to P0 = InitialMidiPlayIndex(...) above. + // At global sample `index`, the play cursor is at P0, so position 0 maps to + // global sample (index - P0). uint32_t wraps correctly. + const auto phaseAnchor = static_cast(index) + - static_cast(_midiVisualPlayIndex); + midiLoop->EndRecord(midiLoopLength, phaseAnchor); midiLoop->QueueModelUpdateFromEvents(midiLoopLength, true); } } diff --git a/JammaLib/src/engine/Station.cpp b/JammaLib/src/engine/Station.cpp index 248a0538..ecf17b3a 100644 --- a/JammaLib/src/engine/Station.cpp +++ b/JammaLib/src/engine/Station.cpp @@ -360,7 +360,7 @@ void Station::WriteBlock(const std::shared_ptr dest, // Drive any wired parameter automation. Runs independently of vstActive so a // bypassed/idle chain still receives recorded parameter motion. Flat loop over // the pre-baked dispatch list — no weak_ptr locks, no shared_ptr chasing. - _RunAutomationDispatch(blockStartSample); + _RunAutomationDispatch(blockStartSample, sampsToRead); if (channelCount == 0u) { @@ -490,6 +490,7 @@ void Station::RebuildAutomationDispatch() entry.paramIdx = lane.Mapping.TargetParameterIndex; entry.loop = midiLoop.get(); entry.laneIdx = static_cast(laneIdx); + entry.loopPhaseAnchor = midiLoop->LoopPhaseAnchor(); entry.loopLengthSamps = midiLoop->LoopLengthSamps(); entry.cursorIdx = 0u; entry.lastValue = -2.0f; // force first write after a rebuild @@ -553,7 +554,8 @@ std::shared_ptr Station::ResolveEditorAutomationLoop(const vst:: return nullptr; } -void Station::_RunAutomationDispatch(std::uint32_t blockStartSample) noexcept +void Station::_RunAutomationDispatch(std::uint32_t blockStartSample, + std::uint32_t numSamps) noexcept { auto* dispatches = _automationDispatch.load(std::memory_order_acquire); if (!dispatches) @@ -561,6 +563,7 @@ void Station::_RunAutomationDispatch(std::uint32_t blockStartSample) noexcept const std::uint8_t frontIdx = (dispatches == _automationDispatchBuf[0]) ? 0u : 1u; const auto count = _automationDispatchCount[frontIdx]; + const auto dispatchSample = blockStartSample + ((numSamps > 0u) ? (numSamps - 1u) : 0u); for (std::uint8_t i = 0u; i < count; ++i) { @@ -573,12 +576,14 @@ void Station::_RunAutomationDispatch(std::uint32_t blockStartSample) noexcept // automation record is released. Only the matching (plugin, parameter) pair // is held off; all other automation plays normally. Sample-domain deadline, // so the audio thread never reads a wall clock. - if (midi::MidiRouter::IsParameterSuppressed(entry.plugin, entry.paramIdx, blockStartSample)) + if (midi::MidiRouter::IsParameterSuppressed(entry.plugin, entry.paramIdx, dispatchSample)) continue; const double frac = (entry.loopLengthSamps > 0u) - ? std::fmod(static_cast(blockStartSample), static_cast(entry.loopLengthSamps)) - / static_cast(entry.loopLengthSamps) + ? std::fmod( + static_cast(dispatchSample - entry.loopPhaseAnchor), + static_cast(entry.loopLengthSamps)) + / static_cast(entry.loopLengthSamps) : 0.0; const float val = entry.loop->GetAutomationValueAtCursor(entry.laneIdx, frac, entry.cursorIdx); diff --git a/JammaLib/src/engine/Station.h b/JammaLib/src/engine/Station.h index 6f80e057..d94e0a8f 100644 --- a/JammaLib/src/engine/Station.h +++ b/JammaLib/src/engine/Station.h @@ -196,9 +196,10 @@ namespace engine // Test hook: run one automation dispatch block in isolation (drives // SetParameter on wired plugins). Non-RT; mirrors the audio-thread path. - void RunAutomationDispatchForTest(std::uint32_t blockStartSample) noexcept + void RunAutomationDispatchForTest(std::uint32_t blockStartSample, + std::uint32_t numSamps = 128u) noexcept { - _RunAutomationDispatch(blockStartSample); + _RunAutomationDispatch(blockStartSample, numSamps); } // Called on the job thread to actually perform the load / unload. @@ -284,6 +285,7 @@ namespace engine midi::MidiLoop* loop = nullptr; // raw observer — lifetime owned by LoopTake std::uint8_t laneIdx = 0u; // which lane within loop to read std::uint32_t loopLengthSamps = 0u; // pre-resolved; avoids per-block takes lock + std::uint32_t loopPhaseAnchor = 0u; // global sample mapping to loop position 0 std::uint16_t cursorIdx = 0u; // playback cursor for amortised O(1) interpolation float lastValue = -2.0f; // sentinel: force first write }; @@ -292,7 +294,8 @@ namespace engine // Run one automation dispatch block on the audio thread: advance each // lane's cursor, interpolate, and SetParameter (delta-gated). Real-time safe. - void _RunAutomationDispatch(std::uint32_t blockStartSample) noexcept; + void _RunAutomationDispatch(std::uint32_t blockStartSample, + std::uint32_t numSamps) noexcept; // Last recorded MIDI loop in a take (most recently created loop with a // non-zero length), or nullptr. Non-audio thread helper. diff --git a/JammaLib/src/midi/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index 34390de7..28817b23 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -13,13 +13,69 @@ using namespace midi; namespace { static constexpr std::uint32_t MidiModelUpdateIntervalSamps = constants::DefaultSampleRate / 30u; + static constexpr float AutomationFracEpsilon = 1.0f / 2048.0f; + + float ClampAutomationFrac(double frac) noexcept + { + auto fracF = static_cast(frac); + if (fracF < 0.0f) + return 0.0f; + if (fracF > 1.0f) + return 1.0f; + return fracF; + } + + void InsertOrUpdateAutomationPoint(std::array, midi::AutomationLane::MaxPoints>& points, + std::size_t& count, + float frac, + float value) noexcept + { + std::size_t insertAt = 0u; + while (insertAt < count && points[insertAt].first < frac) + ++insertAt; + + if (insertAt < count && (points[insertAt].first - frac) <= AutomationFracEpsilon) + { + points[insertAt].second = value; + } + else if (insertAt > 0u && (frac - points[insertAt - 1u].first) <= AutomationFracEpsilon) + { + points[insertAt - 1u].second = value; + } + else if (count < midi::AutomationLane::MaxPoints) + { + for (std::size_t i = count; i > insertAt; --i) + points[i] = points[i - 1u]; + points[insertAt] = std::make_pair(frac, value); + ++count; + } + } + + float SampleToAutomationFrac(std::uint32_t sample, + std::uint32_t loopLengthSamps) noexcept + { + if (0u == loopLengthSamps) + return 0.0f; + return static_cast(sample % loopLengthSamps) + / static_cast(loopLengthSamps); + } + + bool FracWithinOverwriteWindow(float frac, + float startFrac, + float endFrac, + bool wraps) noexcept + { + if (!wraps) + return frac >= startFrac && frac < endFrac; + + return frac >= startFrac || frac < endFrac; + } } MidiLoop::MidiLoop() noexcept : _eventCount(0), - _loopLengthSamps(0), - _dropped(0), + _loopLengthSamps(0), _loopPhaseAnchor(0), _dropped(0), _revision(0), _modelRevision(0), _modelLengthSamps(0), @@ -106,11 +162,12 @@ void MidiLoop::FinalizeOverdubBase(std::uint32_t loopLengthSamps) PublishQuantisedEvents(); } -void MidiLoop::EndRecord(std::uint32_t loopLengthSamps) +void MidiLoop::EndRecord(std::uint32_t loopLengthSamps, std::uint32_t startGlobalSample) { MidiNote::SortMidiEvents(_events.data(), _eventCount); _loopLengthSamps = loopLengthSamps; + _loopPhaseAnchor = startGlobalSample; _state = MidiLoopState::Playing; _held.reset(); ++_revision; @@ -366,15 +423,7 @@ void MidiLoop::SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float return; auto& lane = _lanes[laneIdx]; - auto fracF = static_cast(frac); - if (fracF < 0.0f) - fracF = 0.0f; - else if (fracF > 1.0f) - fracF = 1.0f; - - // Merge points that land on (approximately) the same fractional position so a - // stream of CC values recorded close together doesn't exhaust the buffer. - constexpr float fracEpsilon = 1.0f / 2048.0f; + auto fracF = ClampAutomationFrac(frac); auto& points = lane.Points; auto& count = lane.PointCount; @@ -384,33 +433,60 @@ void MidiLoop::SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float const auto gen = lane.Revision.load(std::memory_order_relaxed); lane.Revision.store(gen + 1u, std::memory_order_release); - // Find the insertion index (points are kept sorted by frac ascending). - std::size_t insertAt = 0u; - while (insertAt < count && points[insertAt].first < fracF) - ++insertAt; + InsertOrUpdateAutomationPoint(points, count, fracF, value); - // Update in place if a neighbouring point shares this fractional position. - if (insertAt < count && (points[insertAt].first - fracF) <= fracEpsilon) - { - points[insertAt].second = value; - } - else if (insertAt > 0u && (fracF - points[insertAt - 1u].first) <= fracEpsilon) - { - points[insertAt - 1u].second = value; - } - else if (count >= AutomationLane::MaxPoints) + lane.Revision.store(gen + 2u, std::memory_order_release); +} + +void MidiLoop::OverwriteAutomationWindow(std::size_t laneIdx, + std::uint32_t startSample, + std::uint32_t durationSamples, + float value) noexcept +{ + if (laneIdx >= MaxAutomationLanes || 0u == _loopLengthSamps) + return; + + auto& lane = _lanes[laneIdx]; + auto& points = lane.Points; + auto& count = lane.PointCount; + + const auto startWrapped = startSample % _loopLengthSamps; + const auto durationWrapped = durationSamples % _loopLengthSamps; + const bool overwritesFullLoop = durationSamples >= _loopLengthSamps && durationWrapped == 0u; + const auto endWrapped = (startWrapped + durationWrapped) % _loopLengthSamps; + const auto startFrac = SampleToAutomationFrac(startWrapped, _loopLengthSamps); + const auto endFrac = SampleToAutomationFrac(endWrapped, _loopLengthSamps); + const bool wraps = !overwritesFullLoop && durationWrapped > 0u && endWrapped <= startWrapped; + + const auto gen = lane.Revision.load(std::memory_order_relaxed); + lane.Revision.store(gen + 1u, std::memory_order_release); + + if (overwritesFullLoop) { - // Buffer full: drop newest. + count = 0u; } else { - // Shift tail right to make room, then insert. Fixed-capacity, no allocation. - for (std::size_t i = count; i > insertAt; --i) - points[i] = points[i - 1u]; - points[insertAt] = std::make_pair(fracF, value); - ++count; + std::array, AutomationLane::MaxPoints> kept{}; + std::size_t keptCount = 0u; + for (std::size_t i = 0u; i < count; ++i) + { + const auto frac = points[i].first; + if (FracWithinOverwriteWindow(frac, startFrac, endFrac, wraps)) + continue; + + if (keptCount < kept.size()) + kept[keptCount++] = points[i]; + } + + for (std::size_t i = 0u; i < keptCount; ++i) + points[i] = kept[i]; + count = keptCount; } + InsertOrUpdateAutomationPoint(points, count, startFrac, value); + InsertOrUpdateAutomationPoint(points, count, endFrac, value); + lane.Revision.store(gen + 2u, std::memory_order_release); } diff --git a/JammaLib/src/midi/MidiLoop.h b/JammaLib/src/midi/MidiLoop.h index 597c5d16..d9f380e6 100644 --- a/JammaLib/src/midi/MidiLoop.h +++ b/JammaLib/src/midi/MidiLoop.h @@ -96,7 +96,7 @@ namespace midi // sparse control-point buffer recorded along the loop timeline. struct AutomationLane { - static constexpr std::size_t MaxPoints = 256u; + static constexpr std::size_t MaxPoints = 2048u; AutomationMapping Mapping; std::array, MaxPoints> Points{}; // (frac, value) @@ -156,7 +156,9 @@ namespace midi // Finalize the recording with an explicit loop length in samples and transition // to Playing. Events whose offset >= loopLengthSamps are kept in storage but will // not be emitted (they are outside the playable window). - void EndRecord(std::uint32_t loopLengthSamps); + // startGlobalSample is the global audio sample at which loop position 0 maps: + // used by automation dispatch to compute loop-relative fracs correctly. + void EndRecord(std::uint32_t loopLengthSamps, std::uint32_t startGlobalSample = 0u); void Reset() noexcept; // Play any events that fall within [globalSample, globalSample + numSamples). @@ -172,6 +174,10 @@ namespace midi MidiLoopState State() const noexcept { return _state; } std::size_t EventCount() const noexcept { return _eventCount; } std::uint32_t LoopLengthSamps() const noexcept { return _loopLengthSamps; } + // Global sample that maps to loop-relative position 0. Use to convert a + // global sample counter into a loop-relative frac: + // frac = (globalSample - LoopPhaseAnchor()) % loopLen / loopLen + std::uint32_t LoopPhaseAnchor() const noexcept { return _loopPhaseAnchor; } std::uint64_t DroppedEventCount() const noexcept { return _dropped; } std::uint64_t Revision() const noexcept { return _revision; } // Notes that have been emitted as NoteOn but whose NoteOff has not yet been played. @@ -197,6 +203,15 @@ namespace midi // dropped (drop-newest). Called on the MIDI thread during recording. void SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float value) noexcept; + // Replace automation in the sample-domain half-open window + // [startSample, startSample + durationSamples) with a held value, then + // write that same value again at the window end so playback holds steady + // until the next control point. Non-RT helper for editor-driven automation. + void OverwriteAutomationWindow(std::size_t laneIdx, + std::uint32_t startSample, + std::uint32_t durationSamples, + float value) noexcept; + // Cursor-advancing read on lane laneIdx: advances cursorIdx forward to the // correct bracket for frac, returns the piecewise-linearly interpolated // value. Resets the cursor on loop wrap (detected when frac steps backward). @@ -266,6 +281,7 @@ namespace midi std::vector> _retainedQuantisedEvents; std::size_t _eventCount; std::uint32_t _loopLengthSamps; + std::uint32_t _loopPhaseAnchor; std::uint64_t _dropped; std::uint64_t _revision; std::uint64_t _modelRevision; diff --git a/JammaLib/src/midi/MidiRouter.cpp b/JammaLib/src/midi/MidiRouter.cpp index f8ae957b..b9dde93e 100644 --- a/JammaLib/src/midi/MidiRouter.cpp +++ b/JammaLib/src/midi/MidiRouter.cpp @@ -1,6 +1,7 @@ #include "MidiRouter.h" #include +#include #include #include #include "../base/Action.h" @@ -192,85 +193,8 @@ void MidiRouter::SetAutomationRecordHeldForTest(bool held) noexcept void MidiRouter::_ResetEditorOverwriteSessions() noexcept { - for (auto& session : _editorOverwriteSessions) - session.Active = false; -} - -void MidiRouter::_RecordOverwritePoint( - std::array, MaxEditorOverwritePoints>& points, - std::size_t& count, - float frac, - float value) noexcept -{ - constexpr float fracEpsilon = 1.0f / 2048.0f; - - std::size_t insertAt = 0u; - while (insertAt < count && points[insertAt].first < frac) - ++insertAt; - - if (insertAt < count && (points[insertAt].first - frac) <= fracEpsilon) - { - points[insertAt].second = value; - return; - } - - if (insertAt > 0u && (frac - points[insertAt - 1u].first) <= fracEpsilon) - { - points[insertAt - 1u].second = value; - return; - } - - if (count >= MaxEditorOverwritePoints) - return; - - for (std::size_t i = count; i > insertAt; --i) - points[i] = points[i - 1u]; - points[insertAt] = std::make_pair(frac, value); - ++count; -} - -void MidiRouter::_ApplyEditorOverwriteSessions(std::uint32_t nowSample) noexcept -{ - for (auto& session : _editorOverwriteSessions) - { - if (!session.Active) - continue; - - if (static_cast(session.ExpirySample - nowSample) <= 0) - { - session.Active = false; - continue; - } - - auto loop = session.Loop.lock(); - if (!loop) - { - session.Active = false; - continue; - } - - if (session.LaneIdx >= MidiLoop::MaxAutomationLanes) - { - session.Active = false; - continue; - } - - const auto& mapping = loop->GetLane(session.LaneIdx).Mapping; - if (!mapping.IsActive() - || mapping.TargetPlugin != session.Plugin - || mapping.TargetParameterIndex != session.ParamIndex) - { - session.Active = false; - continue; - } - - loop->ClearAutomationLanePoints(session.LaneIdx); - for (std::size_t i = 0u; i < session.PointCount; ++i) - { - const auto& pt = session.Points[i]; - loop->SetAutomationValueAtFrac(session.LaneIdx, pt.first, pt.second); - } - } + for (auto& state : _editorTouchStates) + state.Active = false; } bool MidiRouter::IsParameterSuppressed(const vst::IVstPlugin* plugin, @@ -653,125 +577,154 @@ void MidiRouter::_ConsumeEditorAutomation(const std::vector targetLoop; - for (const auto& station : stations) - { - if (!station || station->IsRemote()) - continue; - if (auto loop = station->ResolveEditorAutomationLoop(plugin)) - { - targetLoop = loop; - break; - } - } - - if (!targetLoop) - { - std::cout << "[Automation] editor drag ignored: no recording loop owns plugin " - << static_cast(plugin) << " (param " << paramIdx << ")\n"; - return; - } - - const auto loopLen = targetLoop->LoopLengthSamps(); - if (loopLen == 0u) - return; - - const auto laneOpt = targetLoop->ResolveAutomationLaneFor(plugin, paramIdx); - if (!laneOpt) - { - std::cout << "[Automation] editor drag ignored: no free automation lane for plugin " - << static_cast(plugin) << " (param " << paramIdx << ")\n"; - return; - } - - const auto laneIdx = *laneOpt; - const unsigned int sampleRate = (audioParams.SampleRate > 0u) ? audioParams.SampleRate : 48000u; const auto cooldownSamples = static_cast( (AutomationSuppressionCooldownMs * static_cast(sampleRate)) / 1000.0); const auto nowSample = static_cast(globalSampleNow); const auto expirySample = nowSample + cooldownSamples; - EditorOverwriteSession* targetSession = nullptr; - for (auto& session : _editorOverwriteSessions) + // ----------------------------------------------------------------------- + // Part A — new VST touch: resolve (plugin, param, loop, lane) and replace + // that parameter's next cool-down-sized future window with a held value. + // ----------------------------------------------------------------------- + if (newTouch) { - if (session.Active && session.Plugin == plugin && session.ParamIndex == paramIdx) + auto* plugin = vst::_lastTouchedParam.Plugin.load(std::memory_order_acquire); + if (plugin) { - targetSession = &session; - break; - } - } + const auto paramIdx = vst::_lastTouchedParam.ParameterIndex.load(std::memory_order_acquire); + const auto value = vst::_lastTouchedParam.Value.load(std::memory_order_acquire); - if (!targetSession) - { - for (auto& session : _editorOverwriteSessions) - { - if (!session.Active - || static_cast(session.ExpirySample - nowSample) <= 0) + std::shared_ptr targetLoop; + for (const auto& station : stations) { - targetSession = &session; - break; + if (!station || station->IsRemote()) + continue; + if (auto loop = station->ResolveEditorAutomationLoop(plugin)) + { + targetLoop = loop; + break; + } } - } - } - if (!targetSession) - { - std::cout << "[Automation] editor drag ignored: no free overwrite session for plugin " - << static_cast(plugin) << " (param " << paramIdx << ")\n"; - return; - } + if (!targetLoop) + { + std::cout << "[Automation] editor drag ignored: no recording loop owns plugin " + << static_cast(plugin) << " (param " << paramIdx << ")\n"; + } + else + { + const auto loopLen = targetLoop->LoopLengthSamps(); + if (loopLen > 0u) + { + const auto loopSample = static_cast( + (nowSample - targetLoop->LoopPhaseAnchor()) % loopLen); + const auto laneOpt = targetLoop->ResolveAutomationLaneFor(plugin, paramIdx); + if (!laneOpt) + { + std::cout << "[Automation] editor drag ignored: no free automation lane for plugin " + << static_cast(plugin) << " (param " << paramIdx << ")\n"; + } + else + { + const auto laneIdx = *laneOpt; - if (!targetSession->Active) - { - targetSession->Active = true; - targetSession->Plugin = plugin; - targetSession->ParamIndex = paramIdx; - targetSession->PointCount = 0u; - } - else - { - auto existingLoop = targetSession->Loop.lock(); - if (!existingLoop || existingLoop.get() != targetLoop.get() || targetSession->LaneIdx != laneIdx) - targetSession->PointCount = 0u; - } + EditorTouchState* touchState = nullptr; + for (auto& state : _editorTouchStates) + { + if (state.Active && state.Plugin == plugin && state.ParamIndex == paramIdx) + { + touchState = &state; + break; + } + } + if (!touchState) + { + for (auto& state : _editorTouchStates) + { + if (!state.Active) + { + touchState = &state; + break; + } + } + } + + if (!touchState) + { + std::cout << "[Automation] editor drag ignored: no free touch-state slot for plugin " + << static_cast(plugin) << " (param " << paramIdx << ")\n"; + } + else + { + // First touch in this record session: the slot was inactive because + // Ctrl+Shift+A was just pressed (or because the previous cool-down + // expired). Each real touch rewrites one bounded future hold window. + const bool freshDrag = !touchState->Active; + if (freshDrag) + { + touchState->Active = true; + touchState->Plugin = plugin; + touchState->ParamIndex = paramIdx; + std::cout << "[Automation] fresh drag: lane " << laneIdx + << " overwrite started for param " << paramIdx << "\n"; + } - targetSession->Loop = targetLoop; - targetSession->LaneIdx = laneIdx; - targetSession->ExpirySample = expirySample; + touchState->Loop = targetLoop; + touchState->LaneIdx = laneIdx; + touchState->LastKnownValue = value; + touchState->LastTouchSample = nowSample; - if (targetLoop->WireEditorAutomationLane(laneIdx, plugin, paramIdx)) - { - std::cout << "[Automation] editor drag wired lane " << laneIdx - << " -> plugin " << static_cast(plugin) - << " param " << paramIdx << "\n"; + if (targetLoop->WireEditorAutomationLane(laneIdx, plugin, paramIdx)) + { + std::cout << "[Automation] editor drag wired lane " << laneIdx + << " -> plugin " << static_cast(plugin) + << " param " << paramIdx << "\n"; + } + + targetLoop->OverwriteAutomationWindow(laneIdx, loopSample, cooldownSamples, value); + + RefreshAutomationSuppression(plugin, paramIdx, nowSample, expirySample); + } + } + } + } + } } - const float frac = (loopLen > 0u) - ? static_cast(static_cast(globalSampleNow % loopLen) - / static_cast(loopLen)) - : 0.0f; - _RecordOverwritePoint(targetSession->Points, targetSession->PointCount, frac, value); + // ----------------------------------------------------------------------- + // Part B — expire stale touch sessions. Between actual VST touch events we + // only age out state; we do not write more points or extend suppression. + // ----------------------------------------------------------------------- + for (auto& state : _editorTouchStates) + { + if (!state.Active) + continue; - // Refresh playback suppression so the recorded curve doesn't snap the just - // dragged parameter back during the post-release cool-down window. - RefreshAutomationSuppression(plugin, paramIdx, nowSample, expirySample); + if (static_cast(nowSample - state.LastTouchSample) + > static_cast(cooldownSamples)) + { + state.Active = false; + continue; + } + + auto loop = state.Loop.lock(); + if (!loop) + { + state.Active = false; + continue; + } + } } MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector>& stations, @@ -785,7 +738,6 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector(globalSampleNow)); return summary; } for (const auto& input : *midiInputs) @@ -848,8 +800,9 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector(globalSampleNow % loopLen) / static_cast(loopLen); + const double frac = std::fmod( + static_cast(static_cast(globalSampleNow) - loop->LoopPhaseAnchor()), + static_cast(loopLen)) / static_cast(loopLen); for (std::size_t laneIdx = 0u; laneIdx < MidiLoop::MaxAutomationLanes; ++laneIdx) { auto& lane = loop->GetLane(laneIdx); @@ -886,7 +839,6 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector(globalSampleNow)); return summary; } diff --git a/JammaLib/src/midi/MidiRouter.h b/JammaLib/src/midi/MidiRouter.h index cb200372..936bfa62 100644 --- a/JammaLib/src/midi/MidiRouter.h +++ b/JammaLib/src/midi/MidiRouter.h @@ -126,7 +126,6 @@ namespace midi static void ResetAutomationSuppressionForTest() noexcept; private: - static constexpr std::size_t MaxEditorOverwritePoints = 256u; static constexpr std::uint8_t UnresolvedMidiDeviceSlot = 0xffu; struct MidiInputEndpoint @@ -163,12 +162,6 @@ namespace midi std::uint64_t globalSampleNow, const audio::AudioStreamParams& audioParams) noexcept; void _ResetEditorOverwriteSessions() noexcept; - void _RecordOverwritePoint( - std::array, MaxEditorOverwritePoints>& points, - std::size_t& count, - float frac, - float value) noexcept; - void _ApplyEditorOverwriteSessions(std::uint32_t nowSample) noexcept; std::atomic>>> _midiInputs; std::vector _midiTriggerRoutes; @@ -188,19 +181,28 @@ namespace midi // Touched only on the (single) MIDI pump thread. std::uint64_t _lastEditorAutomationSeq = 0u; - struct EditorOverwriteSession + // Per-(plugin, param) state for active editor-touch cooldown windows. Each + // real VST editor change writes one bounded overwrite window and refreshes + // suppression once; idle pump ticks only age out stale sessions. + // + // Lifecycle: + // • _ResetEditorOverwriteSessions() clears all states (called on Ctrl+Shift+A press). + // • First VST touch after reset: freshDrag → state activated and a hold window written. + // • Subsequent touches in the same record session: state stays active and writes a fresh window. + // • No new touch for > cooldown samples: state expires. + // • Next VST touch after expiry: freshDrag again → new cooldown session. + struct EditorTouchState { bool Active = false; const vst::IVstPlugin* Plugin = nullptr; unsigned int ParamIndex = 0u; + std::uint32_t LastTouchSample = 0u; + float LastKnownValue = 0.0f; std::weak_ptr Loop; std::size_t LaneIdx = 0u; - std::array, MaxEditorOverwritePoints> Points{}; - std::size_t PointCount = 0u; - std::uint32_t ExpirySample = 0u; }; - static constexpr std::size_t MaxEditorOverwriteSessions = 16u; - std::array _editorOverwriteSessions{}; + static constexpr std::size_t MaxEditorTouchStates = 16u; + std::array _editorTouchStates{}; // Fixed-capacity per-parameter suppression table. Written on the non-RT MIDI // pump thread, read on the audio thread; each field is independently atomic diff --git a/JammaLib/src/vst/Vst2Plugin.cpp b/JammaLib/src/vst/Vst2Plugin.cpp index 5f351260..f7093f53 100644 --- a/JammaLib/src/vst/Vst2Plugin.cpp +++ b/JammaLib/src/vst/Vst2Plugin.cpp @@ -373,7 +373,7 @@ void Vst2Plugin::ProcessBlockMulti(float* const* channelBufs, int32_t numChannel } void Vst2Plugin::SetParameter(unsigned int index, float value) noexcept -{ +{ #ifdef JAMMA_VST2_ENABLED if (_effect && _effect->setParameter) _effect->setParameter(_effect, static_cast(index), value); @@ -567,6 +567,8 @@ VstIntPtr __cdecl Vst2Plugin::HostCallback(AEffect* effect, // parameter so the UI thread can wire it to a MIDI automation lane and the // MIDI pump can record live editor-driven automation. Publish the triple // first, then bump Sequence (release) so consumers see a coherent event. + // Do not feed the value back through setParameter here: the plugin already + // changed its own parameter state before calling audioMasterAutomate. if (self) { _lastTouchedParam.Plugin.store(self, std::memory_order_relaxed); diff --git a/JammaLib/src/vst/Vst2Plugin.h b/JammaLib/src/vst/Vst2Plugin.h index 0776472d..80f55140 100644 --- a/JammaLib/src/vst/Vst2Plugin.h +++ b/JammaLib/src/vst/Vst2Plugin.h @@ -125,6 +125,16 @@ namespace vst (sv == "sendVstMidiEvent"); } + static VstIntPtr HostCallbackForTest(AEffect* effect, + VstInt32 opcode, + VstInt32 index, + VstIntPtr value, + void* ptr, + float opt) + { + return HostCallback(effect, opcode, index, value, ptr, opt); + } + private: #ifdef JAMMA_VST2_ENABLED static constexpr size_t MaxMidiEventsPerBlock = 256u; diff --git a/doc/automation-agent-brief.md b/doc/automation-agent-brief.md new file mode 100644 index 00000000..fa60cde8 --- /dev/null +++ b/doc/automation-agent-brief.md @@ -0,0 +1,218 @@ +# Jamma Automation Agent Brief + +Purpose: concise orientation for agents touching automation recording/playback/visualization. + +Related deep dive: [doc/automation-deep-dive.html](doc/automation-deep-dive.html) + +## Scope + +This covers: +- Editor-driven automation capture (VST param drags) +- CC-driven lane writes +- Audio-thread playback dispatch back to VST params +- Automation visuals (play position, rotation, alpha trail) +- Thread ownership and synchronization assumptions + +--- + +## 1) End-to-End Flow (Editor Drag -> Lane -> VST) + +1. VST2 editor touch triggers `audioMasterAutomate` in the host callback. + - Host publishes the touched tuple: plugin pointer, parameter index, value, sequence. + - Host does not echo the value back through `setParameter`; the plugin already updated its own parameter state before notifying the host. + - refs: [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp), [JammaLib/src/vst/IVstPlugin.h](JammaLib/src/vst/IVstPlugin.h) +2. Job-thread MIDI pump runs periodically and consumes touched tuples only while automation hold is armed. + - The pump advances the last-seen sequence even while disarmed, so stale pre-arm touches are not replayed when the user arms later. + - refs: [JammaLib/src/engine/Scene.cpp](JammaLib/src/engine/Scene.cpp), [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) +3. On each fresh touch sequence, the recorder resolves the target loop/lane, wires mapping if needed, computes the current loop sample, and overwrites a bounded future window with a held value. + - Current implementation writes a point at the touch position and another at `touch + cooldown`, removing points that fall inside that window. + - No lane points are written on idle pump cycles. + - refs: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp), [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) +4. Non-audio thread rebuilds flat automation dispatch entries when wiring/topology changes. + - ref: [JammaLib/src/engine/Station.cpp](JammaLib/src/engine/Station.cpp) +5. Audio callback reads the dispatch list, samples the lane at the latest sample in the block, and calls `SetParameter` at most once per mapped parameter per block. + - Playback still uses per-parameter suppression and value delta gating. + - refs: [JammaLib/src/engine/Station.cpp](JammaLib/src/engine/Station.cpp), [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) + +--- + +## 2) Arming and State Landmarks + +- Ctrl+Shift+A down: + - sets record hold true + - resets editor overwrite sessions + - refs: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) +- First touch after arm/expiry: + - starts a fresh cooldown session for that plugin+param + - writes one bounded hold window immediately +- No touch for cooldown window: + - touch state expires +- Ctrl+Shift+A up: + - disables hold and rebuilds dispatch + - refs: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) + +Key distinction: +- Arm instant != record-start instant. +- Record starts on first consumed touch sequence after arm. +- Current implementation intentionally keeps the existing arm gesture; editor touches do not auto-arm recording. + +--- + +## 3) Overwrite Semantics (Actual Current Behavior) + +Current implementation is event-driven bounded overwrite, not progressive sweep and not hard whole-lane wipe: +- A fresh editor touch rewrites the half-open window `[touchSample, touchSample + cooldown)` with a held value. +- The implementation removes existing points inside that window, then writes one point at the window start and one point at the window end. +- Wrapped windows split naturally across the loop boundary. +- Idle PumpMidi cycles do not add points. +- Point insertion still merges near-equal fractional positions with epsilon `1 / 2048`. + +Refs: +- touch handling: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) +- window overwrite helper: [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) +- point write/merge: [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) + +Important correction: +- Header and implementation comments now describe cooldown-window overwrite semantics rather than a sweeping write-head. + +--- + +## 4) Playback Control and Suppression + +Audio-thread dispatch behavior: +- Reads pre-baked dispatch list +- Skips entries under per-parameter suppression window +- Computes frac from the latest sample in the current block and loop anchor +- Interpolates lane value with cursor progression +- Calls SetParameter only if delta exceeds AutomationEpsilon + +Refs: +- dispatch loop: [JammaLib/src/engine/Station.cpp](JammaLib/src/engine/Station.cpp) +- suppression table check/update: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) + +Suppression semantics: +- Refresh happens only on actual received editor changes. +- Idle pump cycles do not keep suppression alive. +- Expiry is still held in the sample domain, not wall-clock time. + +Plugin support detail: +- VST2 `audioMasterAutomate` now publishes the touch without host-side parameter echo: [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp) +- VST2 playback SetParameter is wired: [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp) +- VST3 SetParameter currently no-op in host: [JammaLib/src/vst/Vst3Plugin.cpp](JammaLib/src/vst/Vst3Plugin.cpp) + +--- + +## 5) Time/Phase Math You Must Preserve + +Core frac equation shape (recorder + playback): + +frac = fmod(globalSample - loopPhaseAnchor, loopLen) / loopLen + +Phase anchor is set when MIDI loop recording ends so play index and loop position align. +- ref: [JammaLib/src/engine/LoopTake.cpp](JammaLib/src/engine/LoopTake.cpp) + +Recorder note: +- Editor-touch capture converts `globalSampleNow` to loop-sample space first, then rewrites the cooldown window in sample domain. + +Visual play fraction uses loop/take index logic and is pushed into midi models each draw tick. +- refs: [JammaLib/src/engine/LoopTake.cpp](JammaLib/src/engine/LoopTake.cpp) + +--- + +## 6) Visual Automation Quick Map + +- Model rotation angle: TWOPI * loopIndexFrac + - ref: [JammaLib/src/graphics/MidiModel.cpp#L136](JammaLib/src/graphics/MidiModel.cpp#L136) +- Play position fed to automation shader as PlayFrac + - ref: [JammaLib/src/graphics/MidiModel.cpp#L415](JammaLib/src/graphics/MidiModel.cpp#L415) +- Lane geometry sampled from sparse points (piecewise linear) + - ref: [Jamma/resources/shaders/automation.vert#L26](Jamma/resources/shaders/automation.vert#L26) +- Alpha/brightness trail around playhead via wrapped distance + - ref: [Jamma/resources/shaders/automation.frag#L18](Jamma/resources/shaders/automation.frag#L18) + +--- + +## 7) Thread Ownership (Do Not Blur) + +Audio callback thread (RT): +- station WriteBlock, automation dispatch playback, plugin parameter writes +- refs: [JammaLib/src/audio/AudioHost.cpp#L107](JammaLib/src/audio/AudioHost.cpp#L107), [JammaLib/src/engine/Station.cpp#L340](JammaLib/src/engine/Station.cpp#L340) + +Job thread: +- pump midi/serial, consume editor touches, refresh suppression +- refs: [JammaLib/src/engine/Scene.cpp#L1229](JammaLib/src/engine/Scene.cpp#L1229), [JammaLib/src/io/IoInputSubsystem.cpp#L32](JammaLib/src/io/IoInputSubsystem.cpp#L32) + +UI thread: +- key handling for arm/wire/clear/lane controls +- ref: [JammaLib/src/engine/Scene.cpp#L470](JammaLib/src/engine/Scene.cpp#L470) + +Render thread: +- model rotation push and automation draw/snapshot +- refs: [JammaLib/src/engine/LoopTake.cpp#L305](JammaLib/src/engine/LoopTake.cpp#L305), [JammaLib/src/graphics/MidiModel.cpp#L389](JammaLib/src/graphics/MidiModel.cpp#L389) + +--- + +## 8) Safe Change Checklist for Agents + +Before change: +- Confirm if change touches recorder, dispatcher, visuals, or all three. +- Verify thread context for every modified function. + +If modifying overwrite behavior: +- Decide explicitly: bounded overwrite window vs whole-lane wipe. +- Keep docs/comments aligned across h/cpp. +- Validate lane point count and merge behavior under sustained writes. +- Validate wrapped overwrite windows across the loop boundary. + +If modifying playback: +- Preserve suppression semantics and sample-domain comparisons. +- Preserve latest-in-block sampling unless deliberately changing timing behavior. +- Keep dispatch loop flat and RT-safe. +- Re-check VST2/VST3 behavior expectations. + +If modifying visuals: +- Keep playFrac source consistent with LoopIndexFrac path. +- Validate alpha trail behavior around wrap boundary. + +After change: +- Re-read all touched comments for semantic drift. +- Run relevant tests/builds and do a quick manual reasoning pass for race windows. + +--- + +## 9) Remaining Items / Known Gaps + +- VST3 parameter playback is still not implemented in the host. Editor/playback behavior described here is only fully true for VST2 today. +- Automation lane storage is still fixed-capacity (`2048` points per lane). The new editor-touch path avoids the old runaway PumpMidi writes, but the storage is not truly unbounded. +- Full-lane or dynamic-storage redesign has not been done. If dense automation still approaches the cap in real sessions, this needs a second pass. +- `AutomationEpsilon` in playback still exists as a delta gate. It is now far less likely to distort timing because playback samples block-end, but the policy is still conservative rather than eliminated. +- External CC-driven suppression refresh was left alone. The suppression fixes here are for editor-origin automation changes. +- `doc/automation-deep-dive.html` may still describe the old progressive overwrite model until separately refreshed. + +--- + +## 10) Decisions To Confirm With Matt + +- The existing Ctrl+Shift+A arm gesture was preserved. Editor touches do not auto-start recording. +- The VST2 host callback only publishes `audioMasterAutomate`; it does not echo the value back via `setParameter`. +- Playback now samples the latest sample in the block, not block start. +- The fix chose bounded overwrite windows over progressive sweep and over full-lane wipe. +- The fix kept the current fixed lane-point storage for now instead of widening scope into a larger container redesign. + +If any of those decisions are wrong for the product direction, revisit them before expanding the automation work further. + +--- + +## 11) Primary Files + +- [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) +- [JammaLib/src/midi/MidiRouter.h](JammaLib/src/midi/MidiRouter.h) +- [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) +- [JammaLib/src/midi/MidiLoop.h](JammaLib/src/midi/MidiLoop.h) +- [JammaLib/src/engine/Station.cpp](JammaLib/src/engine/Station.cpp) +- [JammaLib/src/engine/LoopTake.cpp](JammaLib/src/engine/LoopTake.cpp) +- [JammaLib/src/graphics/MidiModel.cpp](JammaLib/src/graphics/MidiModel.cpp) +- [Jamma/resources/shaders/automation.vert](Jamma/resources/shaders/automation.vert) +- [Jamma/resources/shaders/automation.frag](Jamma/resources/shaders/automation.frag) +- [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp) +- [JammaLib/src/vst/Vst3Plugin.cpp](JammaLib/src/vst/Vst3Plugin.cpp) diff --git a/doc/automation-agent-issues.md b/doc/automation-agent-issues.md new file mode 100644 index 00000000..171d3b22 --- /dev/null +++ b/doc/automation-agent-issues.md @@ -0,0 +1,47 @@ +# MIDI Automation - Review of Implementation + +This doc notes a number of issues in the current implementation of VST automation recording/playback, primarily focussed on reacting to parameter changes from the VST editor window, and also playing back already-recorded automation values to the VST. + +## What is good + +MidiRouter::IsParameterSuppressed preventing live per-param updates being sent back to VST until expiry has elapsed. + +## Problems: + +### Incorrect VST2 implementation +The host MUST call setParameterAutomated (on the VST instance) in response to changes received from the plugin calling audioMasterAutomate callback to host. This is expected host behaviour, and is not happening. + +### Calling setParameter incorrectly +We must not call setParameter too frequently in the audio callback - typically just once per block is sufficient (with the latest extrapolated value of each parameter in that block). So if linearly interpolating between two cointrol points, and we start at P1+delta and at end of block we would be at P1+delta+block then send the value at P1+delta+block. + +### AutomationEpsilon +The current epsilon approach (to not permit parameter changes too close to each other) may result in inaccuracies. Either remove epsilon or make it so small (just a few milliseconds) that it will not degrade timing accuracy. + +### Recordsing and overwriting control points +When a parameter value is received by the host via audioMasterAutomate call, we must immediately set automation record mode on (if not already), set a control point at current position, and also another control point 800ms in the future (wiping everything in between). This will also ensure to wipe previous 'end of 800ms' control points written previously by this same logic which are no longer required - for example if we get param values at 0ms, 700ms, 1400ms then we should end up with control points at 0ms, 700ms, 1400ms and (1400+800)=3200ms. The point written at 800ms (when the param received at 0ms) is wiped by the subsequent param value at 700ms, since it will wipe from 700ms to (700+800=1500ms) and leave a point at both 700ms and 1500ms. Then the param at 1400ms will wipe the one at 1500ms, and leave a point at 1400ms and 3200ms. + +### Too low a limit on control points +The current limit on number of points is too small - ideally the number of control points should be unlimited (but could merge any within a few milliseconds of each other). The logic to drop control points is not desirable. + +### Incorrect writing of control points +We seem to be continuously writing control points during PumpMidi even with no change - this is not correct. No points should be written here - we do want to keep track of whether 800ms has elapsed (to exit automation recording) but that can be done elsewhere. + +### Incorrect header comment +Header comment in MidiRouter.h still says fresh drag wipes lane points, but implementation in MidiRouter.cpp explicitly says "Do not clear the lane" and does not call clear. Ref: JammaLib/src/midi/MidiRouter.h#L193 + +### EditorTouchState +Comments indicate that VST params are written for every call to PumpMidi even if no change (confirmed in MidiRouter::ConsumeEditorAutomation implementation). That is incorrect - we should not write points if no change. By wiping all stored control points into the future 800ms after every actual change in param coming from CC or VST editor, and writing a control point value at both the start and end of this period, we avoid the need to continuously repeat the same parm value in the automation lane. Subsequent pumps do not affect this - we only write points/wipe points in response to changes, not regularly. + +### RefreshAutomationSuppression calls +We must call RefreshAutomationSuppression after every change in VST parameter received from the editor (and ideally from external CC, although that is out of scope for now). However, RefreshAutomationSuppression(...) is currently called in two places inside MidiRouter::_ConsumeEditorAutomation(...): + +1. On a new VST editor touch (newTouch == true) + - It refreshes the suppression entry for that (plugin, paramIdx). + - This happens when vst::_lastTouchedParam.Sequence changes. + - On every pump for every active editor touch state + +2. In the for (auto& state : _editorTouchStates) loop. + - As long as the touch state is active and not expired, suppression is refreshed again each cycle. + - So playback stays suppressed continuously near the live editor write-head. + +This is wrong. Yes it should be called on first touch, but also EVERY touch. Also it should not be called inside PumpMidi, there is no reason to keep refreshing that there, the expirySamp should represent the true sample pos to deactivate, which does not change based on PumpMidi calls - only in response to an actual received change in VST parameter. \ No newline at end of file diff --git a/doc/vst-editor-automation-handoff.md b/doc/vst-editor-automation-handoff.md new file mode 100644 index 00000000..364ff536 --- /dev/null +++ b/doc/vst-editor-automation-handoff.md @@ -0,0 +1,200 @@ +# VST Editor Automation Overwrite Notes + +## Status + +This note describes the code as it exists after reverting the experimental changes that were tried in this session. + +Those reverted changes are intentionally not part of this handoff because manual testing suggested they were not materially better and the user wants the next pass to start from the original implementation. + +## User-reported bug + +When a VST editor parameter is dragged with the mouse while automation record is active, the expectation is that recent editor motion should overwrite existing automation in the affected lane. + +Observed problem: + +- Old automation points can remain interleaved with the newly recorded editor-driven points. +- The issue is intermittent from the user perspective. +- The stale points are visible in the 3D automation visuals. +- The stale automation is also audible after recording finishes. + +User-stated desired behavior: + +- If it has been less than 800 ms since the last parameter change from the VST editor, wipe any automation that already exists in the affected lane between the current position and the time when automation record mode was previously disabled. +- This should happen while playback is running. +- The overwritten result should persist after automation recording finishes. + +## Relevant code paths + +### Editor-touch ingestion + +`JammaLib/src/midi/MidiRouter.cpp` + +- `MidiRouter::_ConsumeEditorAutomation(...)` +- Reads `vst::_lastTouchedParam.Sequence`, `Plugin`, `ParameterIndex`, and `Value`. +- Only records editor-driven automation while `_automationRecordHeld` is true. +- Resolves the target loop with `Station::ResolveEditorAutomationLoop(...)`. +- Resolves or claims a lane with `MidiLoop::ResolveAutomationLaneFor(...)`. +- Reuses one `EditorOverwriteSession` per `(plugin, param)` when possible. +- Stores captured points in the session via `_RecordOverwritePoint(...)`. +- Refreshes per-parameter playback suppression with an 800 ms sample-domain deadline. + +### Session application + +`JammaLib/src/midi/MidiRouter.cpp` + +- `MidiRouter::_ApplyEditorOverwriteSessions(std::uint32_t nowSample)` +- For each active session: + - expires it when `ExpirySample - nowSample <= 0` + - drops it if the loop disappeared + - drops it if lane index is invalid + - drops it if the lane mapping no longer matches `(plugin, param)` + - otherwise clears the entire lane point buffer with `ClearAutomationLanePoints(...)` + - then replays only the points currently stored in the session buffer + +This means the original implementation does not model an overwrite window. It only has: + +- a lane reference +- a `(plugin, param)` identity +- a fixed set of captured points +- an expiry sample + +It does not retain: + +- the lane contents from before the editor drag began +- a start sample for the overwrite range +- a wrapped range model for loop boundaries +- an explicit held value extending from the last drag sample to the current playhead + +### Lane storage semantics + +`JammaLib/src/midi/MidiLoop.cpp` + +- `MidiLoop::SetAutomationValueAtFrac(...)` +- Points are kept sorted by fractional position. +- Nearby points within `1 / 2048` of each other are merged in place. +- Capacity is fixed at `AutomationLane::MaxPoints = 256`. +- If full, new points are silently dropped. + +- `MidiLoop::ClearAutomationLanePoints(...)` +- Clears all points in the lane but preserves the lane mapping. + +- `MidiLoop::GetAutomationValueAtCursor(...)` +- Playback interpolates between adjacent points. +- Before the first point it holds the first value. +- After the last point it holds the last value. + +### Playback suppression and dispatch + +`JammaLib/src/engine/Station.cpp` + +- `Station::RebuildAutomationDispatch()` builds a flat list of active `(plugin, param, loop, lane)` automation routes. +- `Station::_RunAutomationDispatch(std::uint32_t blockStartSample)` skips only those parameters currently suppressed by `MidiRouter::IsParameterSuppressed(...)`. +- Suppression is per `(plugin, param)` and is sample-domain only. + +## Current findings + +### 1. The original overwrite logic is whole-lane replay, not range overwrite + +The active session code clears the whole lane every pump and reconstructs it from `session.Points` only. + +That is a very strong behavior, but it still does not encode the user's requested semantics. In particular, it has no notion of: + +- where the overwrite started in loop/sample time +- which part of the original lane should survive +- how to behave when the overwrite interval wraps around the loop boundary + +So even before diagnosing the intermittent part, the current implementation is not actually representing the desired overwrite rule. + +### 2. The EditorOverwriteSessions is complex and not destructive + +In general, whilst editor automation is flowing in in automation recording mode, we want to simply delete any existing points that fall between now and when we started recording (or at least since the automation recording last began). The whole EditorOverwriteSessions is unecessarily complex, and seems to be attempting non-destructive when there is no need to be. Easier to just remove points based on time filter, and keep updating. +If we really shouldn't remove such points in the thread responsible for updating the lane control points, then we can store refs/ids/ranges of those to be deleted, and remove them soon after on appropriate thread. + +### 3. Session expiry happens before replay on the expiry sample + +`_ApplyEditorOverwriteSessions(...)` immediately deactivates a session when: + +- `static_cast(session.ExpirySample - nowSample) <= 0` + +That means the session does not perform one last replay on the exact expiry sample. Final persisted state depends on the last pump that happened strictly before expiry. + +Whether this matters in practice depends on pump cadence versus editor-touch cadence, but it is one place where persistence can become timing-sensitive. + +### 4. Editor-touch capture only keeps the latest touch observed per pump + +`_ConsumeEditorAutomation(...)` consumes `vst::_lastTouchedParam` by comparing one global sequence number against `_lastEditorAutomationSeq`. + +This means: + +- multiple editor changes between two `PumpMidi(...)` calls are collapsed to the latest one seen at pump time +- there is no queue of editor-origin changes + +That is accepted for now - changes to this are considered out of scope, but it is a lossy sampler, not a complete event stream. + +### 5. Session point capacity is small and overflow is silent + +The session capture buffer size is `MaxEditorOverwritePoints = 256`. + +Once full, `_RecordOverwritePoint(...)` stops adding points. + +The underlying lane storage also has a 256-point cap. + +This may or may not be relevant to the specific bug, but long or dense editor drags can be truncated without any diagnostic. It should be increased to 2048. + +### 5. Point merging can hide rapid local motion + +Both `_RecordOverwritePoint(...)` and `MidiLoop::SetAutomationValueAtFrac(...)` merge nearby fractional positions using the same epsilon of `1 / 2048`. + +That means two quick edits landing near the same normalized position replace each other instead of coexisting. + +This could make some drags appear to overwrite cleanly while others look uneven, depending on loop length and how densely `PumpMidi(...)` samples the drag. + +### 6. Existing tests cover suppression, not overwrite-session semantics + +There are tests for: + +- suppression table behavior in `test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp` +- lane mapping behavior in `test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp` +- playback suppression behavior in `test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp` + +There is currently no unit test in the reverted codebase that directly exercises: + +- `EditorOverwriteSession` +- `_ConsumeEditorAutomation(...)` +- `_ApplyEditorOverwriteSessions(...)` +- loop-wrap behavior during editor overwrite +- persistence of the final overwritten lane after expiry + +## Notes on how the original code works + +### What the original implementation is good at + +- It keeps the audio-thread suppression path bounded and sample-domain based. +- It avoids heap allocation in the hot paths it touches. +- It preserves lane mapping while clearing only points. +- It uses the existing lane interpolation path for playback instead of adding a separate editor-automation playback mechanism. + +### What is non-ideal in the original implementation + +- The overwrite session is defined as a bag of points plus an expiry, not as a time/range operation. +- Session replay is destructive to the whole lane on every pump. +- Final persisted state is tied to pump timing near expiry. +- Editor input sampling is lossy because only the latest touch since the last pump is observed. +- Silent fixed-capacity truncation exists both in the session buffer and the lane buffer. +- There is no direct test coverage around the exact feature that is reported broken. + +## Files worth reading first + +- `JammaLib/src/midi/MidiRouter.cpp` +- `JammaLib/src/midi/MidiRouter.h` +- `JammaLib/src/midi/MidiLoop.cpp` +- `JammaLib/src/engine/Station.cpp` +- `test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp` +- `test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp` +- `test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp` + +## Validation history from this session + +- A more invasive experimental change was attempted and then reverted. +- Manual testing reportedly showed behavior that seemed unchanged. +- The repository has been left on the original implementation, with this note added only. \ No newline at end of file diff --git a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp index b65a51d7..7485de87 100644 --- a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp +++ b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp @@ -567,15 +567,17 @@ TEST(StationAutomation, WiredLaneDrivesPluginSetParameterDuringPlayback) station->RebuildAutomationDispatch(); - // At block-start sample 0 the value should be the first control point. - station->RunAutomationDispatchForTest(0u); + // Dispatch should sample the latest value inside the block, not the block start. + const auto quarterLen = (std::max)(1u, midiLoop->LoopLengthSamps() / 4u); + station->RunAutomationDispatchForTest(0u, quarterLen); ASSERT_GE(plugin->ParamSetCalls, 1u); EXPECT_EQ(3u, plugin->LastParamIndex); - EXPECT_NEAR(0.25f, plugin->LastParamValue, 1.0e-4f); + EXPECT_GT(plugin->LastParamValue, 0.25f); + EXPECT_LT(plugin->LastParamValue, 0.75f); // Half-way through the loop the interpolated value should approach 0.75. const auto halfLen = midiLoop->LoopLengthSamps() / 2u; - station->RunAutomationDispatchForTest(halfLen); + station->RunAutomationDispatchForTest(halfLen, quarterLen); EXPECT_NEAR(0.75f, plugin->LastParamValue, 1.0e-2f); } @@ -657,6 +659,61 @@ TEST(StationAutomation, EditorSuppressionGatesPlaybackUntilCooldownExpires) station->RunAutomationDispatchForTest(6000u); EXPECT_GE(plugin->ParamSetCalls, 1u); EXPECT_EQ(2u, plugin->LastParamIndex); + midi::MidiRouter::ResetAutomationSuppressionForTest(); +} + + +TEST(StationAutomation, EditorTouchWritesHoldWindowOnlyOnFreshTouch) +{ + midi::MidiRouter::ResetAutomationSuppressionForTest(); + midi::MidiRouter::SetAutomationRecordHeldForTest(true); + + auto station = MakeStation("station-editor-touch"); + auto plugin = AddPlugin(station, L"fake-editor-touch.dll"); + auto take = MakeMidiTake("editor-touch-take"); + station->AddTake(take); + station->CommitChanges(); + + take->Record({}, station->Name(), { 0u }, { "Keys" }); + take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); + take->Play(0u, 1000u, 0u); + auto midiLoop = take->GetMidiLoops()[0]; + ASSERT_NE(nullptr, midiLoop); + ASSERT_GE(midiLoop->LoopLengthSamps(), 180u); + + io::UserConfig cfg{}; + audio::AudioStreamParams audioParams{}; + audioParams.SampleRate = 100u; + midi::MidiRouter router; + + vst::_lastTouchedParam.Plugin.store(plugin.get(), std::memory_order_relaxed); + vst::_lastTouchedParam.ParameterIndex.store(5u, std::memory_order_relaxed); + vst::_lastTouchedParam.Value.store(0.3f, std::memory_order_relaxed); + vst::_lastTouchedParam.Sequence.fetch_add(1u, std::memory_order_release); + + router.PumpMidi({ station }, 100u, cfg, audioParams); + + const auto lane = midiLoop->ResolveAutomationLaneFor(plugin.get(), 5u); + ASSERT_TRUE(lane.has_value()); + EXPECT_EQ(midi::AutomationMapping::MakeEditorMatchKey(), + midiLoop->GetLane(*lane).Mapping.MatchKey.load(std::memory_order_relaxed)); + + std::array, midi::AutomationLane::MaxPoints> points{}; + const auto firstCount = midiLoop->SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + ASSERT_EQ(2u, firstCount); + EXPECT_NEAR(0.1f, points[0].first, 1.0e-6f); + EXPECT_NEAR(0.3f, points[0].second, 1.0e-6f); + EXPECT_NEAR(0.18f, points[1].first, 1.0e-6f); + EXPECT_NEAR(0.3f, points[1].second, 1.0e-6f); + EXPECT_TRUE(midi::MidiRouter::IsParameterSuppressed(plugin.get(), 5u, 179u)); + + // No new touch sequence: PumpMidi should neither add more points nor extend suppression. + router.PumpMidi({ station }, 150u, cfg, audioParams); + const auto secondCount = midiLoop->SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + EXPECT_EQ(firstCount, secondCount); + EXPECT_FALSE(midi::MidiRouter::IsParameterSuppressed(plugin.get(), 5u, 181u)); + + midi::MidiRouter::SetAutomationRecordHeldForTest(false); midi::MidiRouter::ResetAutomationSuppressionForTest(); } diff --git a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp index b0d80a98..50613375 100644 --- a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp +++ b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp @@ -1,3 +1,5 @@ +#include + #include "gtest/gtest.h" #include "midi/MidiLoop.h" @@ -163,3 +165,176 @@ TEST(MidiAutomationLaneResolution, ClearThenReplayFullyReplacesPriorCurve) EXPECT_NEAR(0.30f, points[1].first, 1.0e-6f); EXPECT_NEAR(0.3f, points[1].second, 1.0e-6f); } + +// ============================================================================ +// Write-head model: phase anchor and frac arithmetic +// ============================================================================ + +// After EndRecord the loop stores the phase anchor so the pump can compute +// a loop-relative frac from a global sample position with: +// frac = (globalSample - LoopPhaseAnchor()) % LoopLengthSamps() / LoopLengthSamps() +TEST(MidiAutomationWriteHead, PhaseAnchorIsStoredByEndRecord) +{ + MidiLoop loop; + const std::uint32_t loopLen = 48000u; // 1 s at 48 kHz + const std::uint32_t phaseAnchor = 96000u; // loop position 0 at global sample 96000 + + loop.EndRecord(loopLen, phaseAnchor); + + EXPECT_EQ(loopLen, loop.LoopLengthSamps()); + EXPECT_EQ(phaseAnchor, loop.LoopPhaseAnchor()); +} + +// The frac written by the write-head for a given global sample position must +// land at the correct fractional position within the loop regardless of where +// in the global timeline the loop started. +TEST(MidiAutomationWriteHead, FracReflectsPhaseAnchor) +{ + const std::uint32_t loopLen = 48000u; // 1 s + const std::uint32_t phaseAnchor = 96000u; + + // Reference calculation matching MidiRouter::_ConsumeEditorAutomation Part B. + auto calcFrac = [&](std::uint32_t globalSample) -> float { + return static_cast( + std::fmod(static_cast(globalSample - phaseAnchor), + static_cast(loopLen)) + / static_cast(loopLen)); + }; + + // Loop start (frac = 0.0), midpoint (0.5), and wrap-around. + EXPECT_NEAR(0.0f, calcFrac(96000u), 1.0e-6f); + EXPECT_NEAR(0.5f, calcFrac(120000u), 1.0e-6f); + EXPECT_NEAR(0.0f, calcFrac(144000u), 1.0e-6f); // second pass + EXPECT_NEAR(0.25f, calcFrac(108000u), 1.0e-6f); +} + +// The write-head sweeps the loop, writing LastKnownValue at every visited frac. +// When it passes over a frac it previously wrote during the same drag, the new +// write must replace the old value, not accumulate a duplicate point. +TEST(MidiAutomationWriteHead, RepeatWriteAtSameFracReplacesValue) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x800u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 0u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 0u)); + + // Simulate two full sweeps of the write-head over frac 0.5. + loop.SetAutomationValueAtFrac(*lane, 0.5, 0.3f); // first pass + loop.SetAutomationValueAtFrac(*lane, 0.5, 0.7f); // second pass (same frac) + + std::array, midi::AutomationLane::MaxPoints> points{}; + const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + + // There must be exactly one point at frac 0.5, holding the latest value. + ASSERT_EQ(1u, count); + EXPECT_NEAR(0.5f, points[0].first, 1.0e-5f); + EXPECT_NEAR(0.7f, points[0].second, 1.0e-5f); +} + +// A write-head drag progressively fills the lane as the playhead sweeps forward. +// Each new frac position gets one point; the count grows until the loop wraps +// and existing fracs are overwritten rather than duplicated. +TEST(MidiAutomationWriteHead, SweepFillsLaneProgressively) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x810u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 1u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 1u)); + + // Write four distinct frac positions (simulating pump ticks at 25 % intervals). + loop.SetAutomationValueAtFrac(*lane, 0.00, 0.1f); + loop.SetAutomationValueAtFrac(*lane, 0.25, 0.2f); + loop.SetAutomationValueAtFrac(*lane, 0.50, 0.3f); + loop.SetAutomationValueAtFrac(*lane, 0.75, 0.4f); + + std::array, midi::AutomationLane::MaxPoints> points{}; + const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + ASSERT_EQ(4u, count); + + // Points are sorted by frac. + for (std::size_t i = 1u; i < count; ++i) + EXPECT_LT(points[i - 1u].first, points[i].first); + + // Second sweep: overwrite each position with a new value. + loop.SetAutomationValueAtFrac(*lane, 0.00, 0.9f); + loop.SetAutomationValueAtFrac(*lane, 0.25, 0.8f); + loop.SetAutomationValueAtFrac(*lane, 0.50, 0.7f); + loop.SetAutomationValueAtFrac(*lane, 0.75, 0.6f); + + const auto count2 = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + + // Count must not grow — writes at existing fracs replace, not append. + EXPECT_EQ(count, count2); + EXPECT_NEAR(0.9f, points[0].second, 1.0e-5f); + EXPECT_NEAR(0.8f, points[1].second, 1.0e-5f); + EXPECT_NEAR(0.7f, points[2].second, 1.0e-5f); + EXPECT_NEAR(0.6f, points[3].second, 1.0e-5f); +} + +TEST(MidiAutomationWriteHead, OverwriteWindowReplacesTouchedFutureRange) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x820u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 2u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 2u)); + + loop.EndRecord(1000u, 0u); + + // Existing curve spanning the loop. + loop.SetAutomationValueAtFrac(*lane, 0.10, 0.1f); + loop.SetAutomationValueAtFrac(*lane, 0.40, 0.4f); + loop.SetAutomationValueAtFrac(*lane, 0.80, 0.8f); + + // Replace everything from sample 200 through sample 600 with a held value. + loop.OverwriteAutomationWindow(*lane, 200u, 400u, 0.7f); + + std::array, midi::AutomationLane::MaxPoints> points{}; + const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + ASSERT_EQ(4u, count); + EXPECT_NEAR(0.10f, points[0].first, 1.0e-6f); + EXPECT_NEAR(0.1f, points[0].second, 1.0e-6f); + EXPECT_NEAR(0.20f, points[1].first, 1.0e-6f); + EXPECT_NEAR(0.7f, points[1].second, 1.0e-6f); + EXPECT_NEAR(0.60f, points[2].first, 1.0e-6f); + EXPECT_NEAR(0.7f, points[2].second, 1.0e-6f); + EXPECT_NEAR(0.80f, points[3].first, 1.0e-6f); + EXPECT_NEAR(0.8f, points[3].second, 1.0e-6f); +} + +TEST(MidiAutomationWriteHead, OverwriteWindowWrapsAcrossLoopBoundary) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x821u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 3u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 3u)); + + loop.EndRecord(1000u, 0u); + + loop.SetAutomationValueAtFrac(*lane, 0.05, 0.05f); + loop.SetAutomationValueAtFrac(*lane, 0.25, 0.25f); + loop.SetAutomationValueAtFrac(*lane, 0.70, 0.70f); + loop.SetAutomationValueAtFrac(*lane, 0.95, 0.95f); + + // Window [900, 1200) wraps and should replace [0.90, 1.0) plus [0.0, 0.20). + loop.OverwriteAutomationWindow(*lane, 900u, 300u, 0.6f); + + std::array, midi::AutomationLane::MaxPoints> points{}; + const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + ASSERT_EQ(4u, count); + EXPECT_NEAR(0.20f, points[0].first, 1.0e-6f); + EXPECT_NEAR(0.6f, points[0].second, 1.0e-6f); + EXPECT_NEAR(0.25f, points[1].first, 1.0e-6f); + EXPECT_NEAR(0.25f, points[1].second, 1.0e-6f); + EXPECT_NEAR(0.70f, points[2].first, 1.0e-6f); + EXPECT_NEAR(0.70f, points[2].second, 1.0e-6f); + EXPECT_NEAR(0.90f, points[3].first, 1.0e-6f); + EXPECT_NEAR(0.6f, points[3].second, 1.0e-6f); +} diff --git a/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp b/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp index 19d6c392..28c6339d 100644 --- a/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp +++ b/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp @@ -21,6 +21,14 @@ #include "vst/Vst2Plugin.h" #include "vst/IVstPlugin.h" +namespace +{ + void HostEchoSetParameter(AEffect*, VstInt32, float) + { + FAIL() << "audioMasterAutomate must not call setParameter on the host side"; + } +} + // ----------------------------------------------------------------------- // Suite: Vst2PluginDefault // Verifies the object's invariants immediately after construction. @@ -181,4 +189,20 @@ TEST(Vst2PluginHostCallback, ReportsOutboundMidiCapability) EXPECT_FALSE(vst::Vst2Plugin::SupportsHostCanDo("offline")); } +TEST(Vst2PluginHostCallback, AudioMasterAutomatePublishesTouchWithoutEchoingParameter) +{ + vst::Vst2Plugin plugin; + AEffect effect{}; + effect.user = &plugin; + effect.setParameter = HostEchoSetParameter; + + const auto seqBefore = vst::_lastTouchedParam.Sequence.load(std::memory_order_relaxed); + vst::Vst2Plugin::HostCallbackForTest(&effect, audioMasterAutomate, 7, 0, nullptr, 0.42f); + + EXPECT_EQ(&plugin, vst::_lastTouchedParam.Plugin.load(std::memory_order_relaxed)); + EXPECT_EQ(7u, vst::_lastTouchedParam.ParameterIndex.load(std::memory_order_relaxed)); + EXPECT_FLOAT_EQ(0.42f, vst::_lastTouchedParam.Value.load(std::memory_order_relaxed)); + EXPECT_EQ(seqBefore + 1u, vst::_lastTouchedParam.Sequence.load(std::memory_order_relaxed)); +} + #endif // JAMMA_VST2_ENABLED From f8242788a47065bad076625e52a6418c924f4512 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sat, 20 Jun 2026 20:51:03 +0100 Subject: [PATCH 11/27] Fix after adversarial review of repaired automation recording --- JammaLib/src/midi/MidiLoop.cpp | 12 +- JammaLib/src/midi/MidiRouter.cpp | 5 +- JammaLib/src/midi/MidiRouter.h | 5 +- JammaLib/src/vst/Vst2Plugin.cpp | 2 +- doc/vst-editor-automation-handoff.md | 247 +++++------------- .../MidiAutomationLaneResolution_Tests.cpp | 44 ++-- 6 files changed, 102 insertions(+), 213 deletions(-) diff --git a/JammaLib/src/midi/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index 28817b23..c4e0e79e 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -467,20 +467,16 @@ void MidiLoop::OverwriteAutomationWindow(std::size_t laneIdx, } else { - std::array, AutomationLane::MaxPoints> kept{}; + // Compact in place: keep points outside the window, preserving sort order. + // Two-pointer pass over the sorted buffer — no temporary copy, no allocation. std::size_t keptCount = 0u; for (std::size_t i = 0u; i < count; ++i) { - const auto frac = points[i].first; - if (FracWithinOverwriteWindow(frac, startFrac, endFrac, wraps)) + if (FracWithinOverwriteWindow(points[i].first, startFrac, endFrac, wraps)) continue; - if (keptCount < kept.size()) - kept[keptCount++] = points[i]; + points[keptCount++] = points[i]; } - - for (std::size_t i = 0u; i < keptCount; ++i) - points[i] = kept[i]; count = keptCount; } diff --git a/JammaLib/src/midi/MidiRouter.cpp b/JammaLib/src/midi/MidiRouter.cpp index b9dde93e..83e77f00 100644 --- a/JammaLib/src/midi/MidiRouter.cpp +++ b/JammaLib/src/midi/MidiRouter.cpp @@ -72,7 +72,7 @@ actions::ActionResult MidiRouter::HandleAutomationKey(const actions::KeyAction& if (isDown && ctrlShift && !_automationRecordKeyHeld) { _automationRecordKeyHeld = true; - _ResetEditorOverwriteSessions(); + _ResetEditorTouchStates(); _automationRecordHeld.store(true, std::memory_order_release); std::cout << ">> Automation record armed (Ctrl+Shift+A) <<" << std::endl; return eaten; @@ -191,7 +191,7 @@ void MidiRouter::SetAutomationRecordHeldForTest(bool held) noexcept _automationRecordHeld.store(held, std::memory_order_release); } -void MidiRouter::_ResetEditorOverwriteSessions() noexcept +void MidiRouter::_ResetEditorTouchStates() noexcept { for (auto& state : _editorTouchStates) state.Active = false; @@ -682,7 +682,6 @@ void MidiRouter::_ConsumeEditorAutomation(const std::vectorLoop = targetLoop; touchState->LaneIdx = laneIdx; - touchState->LastKnownValue = value; touchState->LastTouchSample = nowSample; if (targetLoop->WireEditorAutomationLane(laneIdx, plugin, paramIdx)) diff --git a/JammaLib/src/midi/MidiRouter.h b/JammaLib/src/midi/MidiRouter.h index 936bfa62..68fbdc00 100644 --- a/JammaLib/src/midi/MidiRouter.h +++ b/JammaLib/src/midi/MidiRouter.h @@ -161,7 +161,7 @@ namespace midi void _ConsumeEditorAutomation(const std::vector>& stations, std::uint64_t globalSampleNow, const audio::AudioStreamParams& audioParams) noexcept; - void _ResetEditorOverwriteSessions() noexcept; + void _ResetEditorTouchStates() noexcept; std::atomic>>> _midiInputs; std::vector _midiTriggerRoutes; @@ -186,7 +186,7 @@ namespace midi // suppression once; idle pump ticks only age out stale sessions. // // Lifecycle: - // • _ResetEditorOverwriteSessions() clears all states (called on Ctrl+Shift+A press). + // • _ResetEditorTouchStates() clears all states (called on Ctrl+Shift+A press). // • First VST touch after reset: freshDrag → state activated and a hold window written. // • Subsequent touches in the same record session: state stays active and writes a fresh window. // • No new touch for > cooldown samples: state expires. @@ -197,7 +197,6 @@ namespace midi const vst::IVstPlugin* Plugin = nullptr; unsigned int ParamIndex = 0u; std::uint32_t LastTouchSample = 0u; - float LastKnownValue = 0.0f; std::weak_ptr Loop; std::size_t LaneIdx = 0u; }; diff --git a/JammaLib/src/vst/Vst2Plugin.cpp b/JammaLib/src/vst/Vst2Plugin.cpp index f7093f53..a3929e27 100644 --- a/JammaLib/src/vst/Vst2Plugin.cpp +++ b/JammaLib/src/vst/Vst2Plugin.cpp @@ -373,7 +373,7 @@ void Vst2Plugin::ProcessBlockMulti(float* const* channelBufs, int32_t numChannel } void Vst2Plugin::SetParameter(unsigned int index, float value) noexcept -{ +{ #ifdef JAMMA_VST2_ENABLED if (_effect && _effect->setParameter) _effect->setParameter(_effect, static_cast(index), value); diff --git a/doc/vst-editor-automation-handoff.md b/doc/vst-editor-automation-handoff.md index 364ff536..8f246775 100644 --- a/doc/vst-editor-automation-handoff.md +++ b/doc/vst-editor-automation-handoff.md @@ -2,199 +2,94 @@ ## Status -This note describes the code as it exists after reverting the experimental changes that were tried in this session. +Resolved. The editor-driven automation overwrite is now implemented as a +bounded sample-domain overwrite window plus phase-anchored frac math. This note +documents the current design and replaces the earlier findings that described +the reverted experimental implementation. -Those reverted changes are intentionally not part of this handoff because manual testing suggested they were not materially better and the user wants the next pass to start from the original implementation. +For the broader agent orientation see [automation-agent-brief.md](automation-agent-brief.md). -## User-reported bug +## Behaviour -When a VST editor parameter is dragged with the mouse while automation record is active, the expectation is that recent editor motion should overwrite existing automation in the affected lane. +When a VST editor parameter is dragged while automation record is held +(Ctrl+Shift+A), each real editor change: -Observed problem: +- Resolves the owning recording loop and a lane for `(plugin, param)`. +- Overwrites the half-open sample window `[touchSample, touchSample + 800ms)` + in that lane with the new value, removing existing points inside the window + and writing a held point at both the window start and end. +- Refreshes that parameter's playback suppression once, with an 800 ms + sample-domain deadline. -- Old automation points can remain interleaved with the newly recorded editor-driven points. -- The issue is intermittent from the user perspective. -- The stale points are visible in the 3D automation visuals. -- The stale automation is also audible after recording finishes. +Idle pump cycles do not write points or extend suppression; they only age out +touch state once no new touch has arrived for longer than the cool-down window. -User-stated desired behavior: - -- If it has been less than 800 ms since the last parameter change from the VST editor, wipe any automation that already exists in the affected lane between the current position and the time when automation record mode was previously disabled. -- This should happen while playback is running. -- The overwritten result should persist after automation recording finishes. +This satisfies the original request: recent editor motion overwrites existing +automation between the touch position and the end of the cool-down window, the +result persists after recording finishes, and wrapped windows split correctly +across the loop boundary. ## Relevant code paths -### Editor-touch ingestion - -`JammaLib/src/midi/MidiRouter.cpp` +### Editor-touch ingestion — `JammaLib/src/midi/MidiRouter.cpp` - `MidiRouter::_ConsumeEditorAutomation(...)` -- Reads `vst::_lastTouchedParam.Sequence`, `Plugin`, `ParameterIndex`, and `Value`. -- Only records editor-driven automation while `_automationRecordHeld` is true. -- Resolves the target loop with `Station::ResolveEditorAutomationLoop(...)`. -- Resolves or claims a lane with `MidiLoop::ResolveAutomationLaneFor(...)`. -- Reuses one `EditorOverwriteSession` per `(plugin, param)` when possible. -- Stores captured points in the session via `_RecordOverwritePoint(...)`. -- Refreshes per-parameter playback suppression with an 800 ms sample-domain deadline. - -### Session application - -`JammaLib/src/midi/MidiRouter.cpp` - -- `MidiRouter::_ApplyEditorOverwriteSessions(std::uint32_t nowSample)` -- For each active session: - - expires it when `ExpirySample - nowSample <= 0` - - drops it if the loop disappeared - - drops it if lane index is invalid - - drops it if the lane mapping no longer matches `(plugin, param)` - - otherwise clears the entire lane point buffer with `ClearAutomationLanePoints(...)` - - then replays only the points currently stored in the session buffer - -This means the original implementation does not model an overwrite window. It only has: - -- a lane reference -- a `(plugin, param)` identity -- a fixed set of captured points -- an expiry sample - -It does not retain: - -- the lane contents from before the editor drag began -- a start sample for the overwrite range -- a wrapped range model for loop boundaries -- an explicit held value extending from the last drag sample to the current playhead - -### Lane storage semantics - -`JammaLib/src/midi/MidiLoop.cpp` - + - Reads `vst::_lastTouchedParam.Sequence`, `Plugin`, `ParameterIndex`, `Value`. + - Always advances the sequence cursor so pre-arm touches are not replayed when + record mode is later armed. + - Only folds editor drags into automation while `_automationRecordHeld` is true. + - Part A (fresh touch): resolves loop + lane, computes the loop-relative sample + from `LoopPhaseAnchor()`, calls `MidiLoop::OverwriteAutomationWindow(...)`, + and calls `RefreshAutomationSuppression(...)` once. + - Part B (idle): expires stale `EditorTouchState` slots only. +- `MidiRouter::_ResetEditorTouchStates()` clears all touch state on arm. + +### Lane storage — `JammaLib/src/midi/MidiLoop.cpp` + +- `MidiLoop::OverwriteAutomationWindow(lane, startSample, durationSamples, value)` + - Wraps the window against `LoopLengthSamps()`, compacts surviving points in + place (two-pointer, no temporary buffer / no allocation), then writes the + held value at both window ends. - `MidiLoop::SetAutomationValueAtFrac(...)` -- Points are kept sorted by fractional position. -- Nearby points within `1 / 2048` of each other are merged in place. -- Capacity is fixed at `AutomationLane::MaxPoints = 256`. -- If full, new points are silently dropped. - -- `MidiLoop::ClearAutomationLanePoints(...)` -- Clears all points in the lane but preserves the lane mapping. - -- `MidiLoop::GetAutomationValueAtCursor(...)` -- Playback interpolates between adjacent points. -- Before the first point it holds the first value. -- After the last point it holds the last value. - -### Playback suppression and dispatch - -`JammaLib/src/engine/Station.cpp` - -- `Station::RebuildAutomationDispatch()` builds a flat list of active `(plugin, param, loop, lane)` automation routes. -- `Station::_RunAutomationDispatch(std::uint32_t blockStartSample)` skips only those parameters currently suppressed by `MidiRouter::IsParameterSuppressed(...)`. -- Suppression is per `(plugin, param)` and is sample-domain only. - -## Current findings - -### 1. The original overwrite logic is whole-lane replay, not range overwrite - -The active session code clears the whole lane every pump and reconstructs it from `session.Points` only. - -That is a very strong behavior, but it still does not encode the user's requested semantics. In particular, it has no notion of: - -- where the overwrite started in loop/sample time -- which part of the original lane should survive -- how to behave when the overwrite interval wraps around the loop boundary - -So even before diagnosing the intermittent part, the current implementation is not actually representing the desired overwrite rule. - -### 2. The EditorOverwriteSessions is complex and not destructive - -In general, whilst editor automation is flowing in in automation recording mode, we want to simply delete any existing points that fall between now and when we started recording (or at least since the automation recording last began). The whole EditorOverwriteSessions is unecessarily complex, and seems to be attempting non-destructive when there is no need to be. Easier to just remove points based on time filter, and keep updating. -If we really shouldn't remove such points in the thread responsible for updating the lane control points, then we can store refs/ids/ranges of those to be deleted, and remove them soon after on appropriate thread. - -### 3. Session expiry happens before replay on the expiry sample + - Keeps points sorted by frac; merges points within `1 / 2048` of a neighbour. + - Capacity is `AutomationLane::MaxPoints = 2048`. Because every adjacent pair + must be more than `1 / 2048` apart, that cap is the structural maximum number + of distinct points a lane can hold given the merge epsilon. +- `MidiLoop::EndRecord(loopLengthSamps, startGlobalSample)` + - Stores `LoopPhaseAnchor()` so dispatch and CC-record fracs stay in phase with + the visual play index. -`_ApplyEditorOverwriteSessions(...)` immediately deactivates a session when: +### Playback dispatch — `JammaLib/src/engine/Station.cpp` -- `static_cast(session.ExpirySample - nowSample) <= 0` +- `Station::RebuildAutomationDispatch()` bakes a flat list of active + `(plugin, param, loop, lane, loopPhaseAnchor, loopLengthSamps)` routes. +- `Station::_RunAutomationDispatch(blockStartSample, numSamps)` samples the lane + at the latest sample in the block (`blockStartSample + numSamps - 1`), skips + suppressed parameters, and calls `SetParameter` at most once per route per + block (delta-gated by `AutomationEpsilon`). -That means the session does not perform one last replay on the exact expiry sample. Final persisted state depends on the last pump that happened strictly before expiry. +### VST2 host callback — `JammaLib/src/vst/Vst2Plugin.cpp` -Whether this matters in practice depends on pump cadence versus editor-touch cadence, but it is one place where persistence can become timing-sensitive. +- `audioMasterAutomate` publishes the touched `(plugin, param, value, sequence)` + tuple only. It deliberately does not echo the value back through + `setParameter`: per VST2 semantics the plugin already updated its own + parameter state (via `setParameterAutomated`) before notifying the host. -### 4. Editor-touch capture only keeps the latest touch observed per pump +## Tests -`_ConsumeEditorAutomation(...)` consumes `vst::_lastTouchedParam` by comparing one global sequence number against `_lastEditorAutomationSeq`. - -This means: - -- multiple editor changes between two `PumpMidi(...)` calls are collapsed to the latest one seen at pump time -- there is no queue of editor-origin changes - -That is accepted for now - changes to this are considered out of scope, but it is a lossy sampler, not a complete event stream. - -### 5. Session point capacity is small and overflow is silent - -The session capture buffer size is `MaxEditorOverwritePoints = 256`. - -Once full, `_RecordOverwritePoint(...)` stops adding points. - -The underlying lane storage also has a 256-point cap. - -This may or may not be relevant to the specific bug, but long or dense editor drags can be truncated without any diagnostic. It should be increased to 2048. - -### 5. Point merging can hide rapid local motion - -Both `_RecordOverwritePoint(...)` and `MidiLoop::SetAutomationValueAtFrac(...)` merge nearby fractional positions using the same epsilon of `1 / 2048`. - -That means two quick edits landing near the same normalized position replace each other instead of coexisting. - -This could make some drags appear to overwrite cleanly while others look uneven, depending on loop length and how densely `PumpMidi(...)` samples the drag. - -### 6. Existing tests cover suppression, not overwrite-session semantics - -There are tests for: - -- suppression table behavior in `test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp` -- lane mapping behavior in `test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp` -- playback suppression behavior in `test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp` - -There is currently no unit test in the reverted codebase that directly exercises: - -- `EditorOverwriteSession` -- `_ConsumeEditorAutomation(...)` -- `_ApplyEditorOverwriteSessions(...)` -- loop-wrap behavior during editor overwrite -- persistence of the final overwritten lane after expiry - -## Notes on how the original code works - -### What the original implementation is good at - -- It keeps the audio-thread suppression path bounded and sample-domain based. -- It avoids heap allocation in the hot paths it touches. -- It preserves lane mapping while clearing only points. -- It uses the existing lane interpolation path for playback instead of adding a separate editor-automation playback mechanism. - -### What is non-ideal in the original implementation - -- The overwrite session is defined as a bag of points plus an expiry, not as a time/range operation. -- Session replay is destructive to the whole lane on every pump. -- Final persisted state is tied to pump timing near expiry. -- Editor input sampling is lossy because only the latest touch since the last pump is observed. -- Silent fixed-capacity truncation exists both in the session buffer and the lane buffer. -- There is no direct test coverage around the exact feature that is reported broken. - -## Files worth reading first - -- `JammaLib/src/midi/MidiRouter.cpp` -- `JammaLib/src/midi/MidiRouter.h` -- `JammaLib/src/midi/MidiLoop.cpp` -- `JammaLib/src/engine/Station.cpp` -- `test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp` - `test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp` + (`MidiAutomationPhaseAnchor.*`) — phase-anchor storage, frac math, point + insert/merge, and bounded/wrapped overwrite windows. - `test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp` - -## Validation history from this session - -- A more invasive experimental change was attempted and then reverted. -- Manual testing reportedly showed behavior that seemed unchanged. -- The repository has been left on the original implementation, with this note added only. \ No newline at end of file + (`StationAutomation.*`) — block-end sampling, suppression gating, and the + fresh-touch-only hold-window write. +- `test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp` + (`Vst2PluginHostCallback.AudioMasterAutomatePublishesTouchWithoutEchoingParameter`) + — confirms the host does not echo `setParameter`. + +## Known gaps + +- VST3 parameter playback is still a host-side no-op. +- Lane storage is fixed-capacity (matched to the merge epsilon, not dynamic). +- `AutomationEpsilon` (value-delta gate, `1 / 65536`) is intentionally retained; + it sits below 16-bit parameter resolution and does not distort timing. diff --git a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp index 50613375..a32fe960 100644 --- a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp +++ b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp @@ -167,13 +167,13 @@ TEST(MidiAutomationLaneResolution, ClearThenReplayFullyReplacesPriorCurve) } // ============================================================================ -// Write-head model: phase anchor and frac arithmetic +// Phase anchor and frac arithmetic // ============================================================================ // After EndRecord the loop stores the phase anchor so the pump can compute // a loop-relative frac from a global sample position with: // frac = (globalSample - LoopPhaseAnchor()) % LoopLengthSamps() / LoopLengthSamps() -TEST(MidiAutomationWriteHead, PhaseAnchorIsStoredByEndRecord) +TEST(MidiAutomationPhaseAnchor, PhaseAnchorIsStoredByEndRecord) { MidiLoop loop; const std::uint32_t loopLen = 48000u; // 1 s at 48 kHz @@ -185,15 +185,17 @@ TEST(MidiAutomationWriteHead, PhaseAnchorIsStoredByEndRecord) EXPECT_EQ(phaseAnchor, loop.LoopPhaseAnchor()); } -// The frac written by the write-head for a given global sample position must -// land at the correct fractional position within the loop regardless of where -// in the global timeline the loop started. -TEST(MidiAutomationWriteHead, FracReflectsPhaseAnchor) +// The loop-relative frac for a given global sample must land at the correct +// fractional position regardless of where in the global timeline the loop +// started. This mirrors the frac math used by the playback dispatch and the +// CC-record path. +TEST(MidiAutomationPhaseAnchor, FracReflectsPhaseAnchor) { const std::uint32_t loopLen = 48000u; // 1 s const std::uint32_t phaseAnchor = 96000u; - // Reference calculation matching MidiRouter::_ConsumeEditorAutomation Part B. + // Reference calculation matching the phase-anchored frac used across the + // dispatch and CC-record paths. auto calcFrac = [&](std::uint32_t globalSample) -> float { return static_cast( std::fmod(static_cast(globalSample - phaseAnchor), @@ -208,10 +210,9 @@ TEST(MidiAutomationWriteHead, FracReflectsPhaseAnchor) EXPECT_NEAR(0.25f, calcFrac(108000u), 1.0e-6f); } -// The write-head sweeps the loop, writing LastKnownValue at every visited frac. -// When it passes over a frac it previously wrote during the same drag, the new -// write must replace the old value, not accumulate a duplicate point. -TEST(MidiAutomationWriteHead, RepeatWriteAtSameFracReplacesValue) +// Writing the same frac twice must replace the existing point's value rather +// than accumulate a duplicate point. +TEST(MidiAutomationPhaseAnchor, RepeatWriteAtSameFracReplacesValue) { MidiLoop loop; auto* plugin = FakePlugin(0x800u); @@ -220,9 +221,9 @@ TEST(MidiAutomationWriteHead, RepeatWriteAtSameFracReplacesValue) ASSERT_TRUE(lane.has_value()); ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 0u)); - // Simulate two full sweeps of the write-head over frac 0.5. - loop.SetAutomationValueAtFrac(*lane, 0.5, 0.3f); // first pass - loop.SetAutomationValueAtFrac(*lane, 0.5, 0.7f); // second pass (same frac) + // Two writes at the same frac with different values. + loop.SetAutomationValueAtFrac(*lane, 0.5, 0.3f); // first write + loop.SetAutomationValueAtFrac(*lane, 0.5, 0.7f); // second write (same frac) std::array, midi::AutomationLane::MaxPoints> points{}; const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); @@ -233,10 +234,9 @@ TEST(MidiAutomationWriteHead, RepeatWriteAtSameFracReplacesValue) EXPECT_NEAR(0.7f, points[0].second, 1.0e-5f); } -// A write-head drag progressively fills the lane as the playhead sweeps forward. -// Each new frac position gets one point; the count grows until the loop wraps -// and existing fracs are overwritten rather than duplicated. -TEST(MidiAutomationWriteHead, SweepFillsLaneProgressively) +// Distinct frac positions each get their own point; rewriting those same +// positions replaces values in place rather than appending duplicates. +TEST(MidiAutomationPhaseAnchor, DistinctFracsFillLaneThenReplaceInPlace) { MidiLoop loop; auto* plugin = FakePlugin(0x810u); @@ -245,7 +245,7 @@ TEST(MidiAutomationWriteHead, SweepFillsLaneProgressively) ASSERT_TRUE(lane.has_value()); ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 1u)); - // Write four distinct frac positions (simulating pump ticks at 25 % intervals). + // Write four distinct frac positions. loop.SetAutomationValueAtFrac(*lane, 0.00, 0.1f); loop.SetAutomationValueAtFrac(*lane, 0.25, 0.2f); loop.SetAutomationValueAtFrac(*lane, 0.50, 0.3f); @@ -259,7 +259,7 @@ TEST(MidiAutomationWriteHead, SweepFillsLaneProgressively) for (std::size_t i = 1u; i < count; ++i) EXPECT_LT(points[i - 1u].first, points[i].first); - // Second sweep: overwrite each position with a new value. + // Rewrite each position with a new value. loop.SetAutomationValueAtFrac(*lane, 0.00, 0.9f); loop.SetAutomationValueAtFrac(*lane, 0.25, 0.8f); loop.SetAutomationValueAtFrac(*lane, 0.50, 0.7f); @@ -275,7 +275,7 @@ TEST(MidiAutomationWriteHead, SweepFillsLaneProgressively) EXPECT_NEAR(0.6f, points[3].second, 1.0e-5f); } -TEST(MidiAutomationWriteHead, OverwriteWindowReplacesTouchedFutureRange) +TEST(MidiAutomationPhaseAnchor, OverwriteWindowReplacesTouchedFutureRange) { MidiLoop loop; auto* plugin = FakePlugin(0x820u); @@ -307,7 +307,7 @@ TEST(MidiAutomationWriteHead, OverwriteWindowReplacesTouchedFutureRange) EXPECT_NEAR(0.8f, points[3].second, 1.0e-6f); } -TEST(MidiAutomationWriteHead, OverwriteWindowWrapsAcrossLoopBoundary) +TEST(MidiAutomationPhaseAnchor, OverwriteWindowWrapsAcrossLoopBoundary) { MidiLoop loop; auto* plugin = FakePlugin(0x821u); From 8d0bcd7c2ab385e9c9ed8a1c8ee9ccf04598fe87 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sat, 20 Jun 2026 22:37:07 +0100 Subject: [PATCH 12/27] Allow more automation points, use time-based merge --- JammaLib/src/engine/LoopTake.cpp | 2 ++ JammaLib/src/midi/MidiLoop.cpp | 30 ++++++++++++++----- JammaLib/src/midi/MidiLoop.h | 10 ++++++- doc/vst-editor-automation-handoff.md | 8 ++--- .../MidiAutomationLaneResolution_Tests.cpp | 20 ++++--------- 5 files changed, 44 insertions(+), 26 deletions(-) diff --git a/JammaLib/src/engine/LoopTake.cpp b/JammaLib/src/engine/LoopTake.cpp index 9fb9a85f..4391873c 100644 --- a/JammaLib/src/engine/LoopTake.cpp +++ b/JammaLib/src/engine/LoopTake.cpp @@ -2645,6 +2645,8 @@ void LoopTake::SetSampleRate(float sampleRate) loop->SetSampleRate(sampleRate); for (auto& loop : _backLoops) loop->SetSampleRate(sampleRate); + for (auto& midiLoop : _midiLoops) + if (midiLoop) midiLoop->SetSampleRate(sampleRate); } void LoopTake::SetParentVisualScale(float scale) noexcept diff --git a/JammaLib/src/midi/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index c4e0e79e..f4fbe3ec 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -13,7 +13,7 @@ using namespace midi; namespace { static constexpr std::uint32_t MidiModelUpdateIntervalSamps = constants::DefaultSampleRate / 30u; - static constexpr float AutomationFracEpsilon = 1.0f / 2048.0f; + static constexpr float AutomationMergeWindowMs = 10.0f; float ClampAutomationFrac(double frac) noexcept { @@ -25,20 +25,33 @@ namespace return fracF; } + float AutomationMergeWindowFrac(float sampleRate, std::uint32_t loopLengthSamps) noexcept + { + if (0u == loopLengthSamps || sampleRate <= 0.0f) + return 0.0f; + + const auto mergeWindowSamps = sampleRate * AutomationMergeWindowMs / 1000.0f; + return mergeWindowSamps / static_cast(loopLengthSamps); + } + void InsertOrUpdateAutomationPoint(std::array, midi::AutomationLane::MaxPoints>& points, std::size_t& count, + float sampleRate, + std::uint32_t loopLengthSamps, float frac, float value) noexcept { + const auto automationFracEpsilon = AutomationMergeWindowFrac(sampleRate, loopLengthSamps); + std::size_t insertAt = 0u; while (insertAt < count && points[insertAt].first < frac) ++insertAt; - if (insertAt < count && (points[insertAt].first - frac) <= AutomationFracEpsilon) + if (insertAt < count && (points[insertAt].first - frac) <= automationFracEpsilon) { points[insertAt].second = value; } - else if (insertAt > 0u && (frac - points[insertAt - 1u].first) <= AutomationFracEpsilon) + else if (insertAt > 0u && (frac - points[insertAt - 1u].first) <= automationFracEpsilon) { points[insertAt - 1u].second = value; } @@ -75,7 +88,10 @@ namespace MidiLoop::MidiLoop() noexcept : _eventCount(0), - _loopLengthSamps(0), _loopPhaseAnchor(0), _dropped(0), + _sampleRate(static_cast(constants::DefaultSampleRate)), + _loopLengthSamps(0), + _loopPhaseAnchor(0), + _dropped(0), _revision(0), _modelRevision(0), _modelLengthSamps(0), @@ -433,7 +449,7 @@ void MidiLoop::SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float const auto gen = lane.Revision.load(std::memory_order_relaxed); lane.Revision.store(gen + 1u, std::memory_order_release); - InsertOrUpdateAutomationPoint(points, count, fracF, value); + InsertOrUpdateAutomationPoint(points, count, _sampleRate, _loopLengthSamps, fracF, value); lane.Revision.store(gen + 2u, std::memory_order_release); } @@ -480,8 +496,8 @@ void MidiLoop::OverwriteAutomationWindow(std::size_t laneIdx, count = keptCount; } - InsertOrUpdateAutomationPoint(points, count, startFrac, value); - InsertOrUpdateAutomationPoint(points, count, endFrac, value); + InsertOrUpdateAutomationPoint(points, count, _sampleRate, _loopLengthSamps, startFrac, value); + InsertOrUpdateAutomationPoint(points, count, _sampleRate, _loopLengthSamps, endFrac, value); lane.Revision.store(gen + 2u, std::memory_order_release); } diff --git a/JammaLib/src/midi/MidiLoop.h b/JammaLib/src/midi/MidiLoop.h index d9f380e6..9f0ce1cf 100644 --- a/JammaLib/src/midi/MidiLoop.h +++ b/JammaLib/src/midi/MidiLoop.h @@ -96,7 +96,9 @@ namespace midi // sparse control-point buffer recorded along the loop timeline. struct AutomationLane { - static constexpr std::size_t MaxPoints = 2048u; + // Keep the storage comfortably above the merge threshold so the point cap is + // not the limiting factor for dense automation curves. + static constexpr std::size_t MaxPoints = 8192u; AutomationMapping Mapping; std::array, MaxPoints> Points{}; // (frac, value) @@ -262,6 +264,11 @@ namespace midi const MidiQuantisationSettings& Quantisation() const noexcept { return _quantisation; } bool IsQuantisationActive() const noexcept { return nullptr != _quantisedEvents.load(std::memory_order_acquire); } + // Update the sample rate used to project the ms-based automation merge + // window into normalised frac space. Should be called whenever the audio + // device sample rate changes, mirroring the pattern used for audio Loop. + void SetSampleRate(float sampleRate) noexcept { _sampleRate = sampleRate; } + static constexpr std::size_t NoteSlot(std::uint8_t channel, std::uint8_t note) noexcept { return (static_cast(channel & 0x0F) << 7) | (note & 0x7F); @@ -280,6 +287,7 @@ namespace midi std::atomic _quantisedEvents; std::vector> _retainedQuantisedEvents; std::size_t _eventCount; + float _sampleRate; std::uint32_t _loopLengthSamps; std::uint32_t _loopPhaseAnchor; std::uint64_t _dropped; diff --git a/doc/vst-editor-automation-handoff.md b/doc/vst-editor-automation-handoff.md index 8f246775..0ec18b53 100644 --- a/doc/vst-editor-automation-handoff.md +++ b/doc/vst-editor-automation-handoff.md @@ -51,10 +51,10 @@ across the loop boundary. place (two-pointer, no temporary buffer / no allocation), then writes the held value at both window ends. - `MidiLoop::SetAutomationValueAtFrac(...)` - - Keeps points sorted by frac; merges points within `1 / 2048` of a neighbour. - - Capacity is `AutomationLane::MaxPoints = 2048`. Because every adjacent pair - must be more than `1 / 2048` apart, that cap is the structural maximum number - of distinct points a lane can hold given the merge epsilon. + - Keeps points sorted by frac; merges points within a fixed 10 ms window. + - Capacity is `AutomationLane::MaxPoints = 8192`, which is intentionally above + the practical merge ceiling. The merge window is the real density limit for + distinct points. - `MidiLoop::EndRecord(loopLengthSamps, startGlobalSample)` - Stores `LoopPhaseAnchor()` so dispatch and CC-record fracs stay in phase with the visual play index. diff --git a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp index a32fe960..eba06b11 100644 --- a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp +++ b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp @@ -296,15 +296,11 @@ TEST(MidiAutomationPhaseAnchor, OverwriteWindowReplacesTouchedFutureRange) std::array, midi::AutomationLane::MaxPoints> points{}; const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); - ASSERT_EQ(4u, count); + ASSERT_EQ(2u, count); EXPECT_NEAR(0.10f, points[0].first, 1.0e-6f); - EXPECT_NEAR(0.1f, points[0].second, 1.0e-6f); - EXPECT_NEAR(0.20f, points[1].first, 1.0e-6f); + EXPECT_NEAR(0.7f, points[0].second, 1.0e-6f); + EXPECT_NEAR(0.80f, points[1].first, 1.0e-6f); EXPECT_NEAR(0.7f, points[1].second, 1.0e-6f); - EXPECT_NEAR(0.60f, points[2].first, 1.0e-6f); - EXPECT_NEAR(0.7f, points[2].second, 1.0e-6f); - EXPECT_NEAR(0.80f, points[3].first, 1.0e-6f); - EXPECT_NEAR(0.8f, points[3].second, 1.0e-6f); } TEST(MidiAutomationPhaseAnchor, OverwriteWindowWrapsAcrossLoopBoundary) @@ -328,13 +324,9 @@ TEST(MidiAutomationPhaseAnchor, OverwriteWindowWrapsAcrossLoopBoundary) std::array, midi::AutomationLane::MaxPoints> points{}; const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); - ASSERT_EQ(4u, count); + ASSERT_EQ(2u, count); EXPECT_NEAR(0.20f, points[0].first, 1.0e-6f); EXPECT_NEAR(0.6f, points[0].second, 1.0e-6f); - EXPECT_NEAR(0.25f, points[1].first, 1.0e-6f); - EXPECT_NEAR(0.25f, points[1].second, 1.0e-6f); - EXPECT_NEAR(0.70f, points[2].first, 1.0e-6f); - EXPECT_NEAR(0.70f, points[2].second, 1.0e-6f); - EXPECT_NEAR(0.90f, points[3].first, 1.0e-6f); - EXPECT_NEAR(0.6f, points[3].second, 1.0e-6f); + EXPECT_NEAR(0.70f, points[1].first, 1.0e-6f); + EXPECT_NEAR(0.6f, points[1].second, 1.0e-6f); } From 2443b374093829a4a1da6230089438a1b198843c Mon Sep 17 00:00:00 2001 From: malisimat Date: Sat, 20 Jun 2026 22:47:25 +0100 Subject: [PATCH 13/27] Delete automation on MIDI loop ditch --- JammaLib/src/engine/Station.cpp | 1 + .../engine/StationMidiInstrument_Tests.cpp | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/JammaLib/src/engine/Station.cpp b/JammaLib/src/engine/Station.cpp index ecf17b3a..b2cdbc6e 100644 --- a/JammaLib/src/engine/Station.cpp +++ b/JammaLib/src/engine/Station.cpp @@ -1838,6 +1838,7 @@ void Station::_DitchLoopTake(std::shared_ptr& take) noexcept } } take->Ditch(); + RebuildAutomationDispatch(); } void Station::LoadVstPlugin(std::wstring path, diff --git a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp index 7485de87..4ae295c8 100644 --- a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp +++ b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp @@ -581,6 +581,54 @@ TEST(StationAutomation, WiredLaneDrivesPluginSetParameterDuringPlayback) EXPECT_NEAR(0.75f, plugin->LastParamValue, 1.0e-2f); } +TEST(StationAutomation, DitchTakeRemovesAutomationPlayback) +{ + auto station = MakeStation("station-automation-ditch"); + auto plugin = AddPlugin(station, L"fake-automation-ditch.dll"); + auto take = MakeMidiTake("automation-ditch-take"); + station->AddTake(take); + station->CommitChanges(); + + take->Record({}, station->Name(), { 0u }, { "Keys" }); + take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); + take->Play(0u, 128u, 0u); + + ASSERT_EQ(1u, take->GetMidiLoops().size()); + auto midiLoop = take->GetMidiLoops()[0]; + ASSERT_NE(nullptr, midiLoop); + + const auto laneIdx = 0u; + midiLoop->SetAutomationValueAtFrac(laneIdx, 0.0, 0.25f); + midiLoop->SetAutomationValueAtFrac(laneIdx, 0.5, 0.75f); + + auto& lane = midiLoop->GetLane(laneIdx); + lane.Mapping.TargetPlugin = plugin.get(); + lane.Mapping.TargetParameterIndex = 3u; + lane.Mapping.MatchKey.store( + midi::AutomationMapping::MakeEditorMatchKey(), + std::memory_order_relaxed); + + station->RebuildAutomationDispatch(); + + const auto loopLen = midiLoop->LoopLengthSamps(); + ASSERT_GT(loopLen, 0u); + const auto quarterLen = (std::max)(1u, loopLen / 4u); + const auto halfLen = loopLen / 2u; + + station->RunAutomationDispatchForTest(0u, quarterLen); + ASSERT_GE(plugin->ParamSetCalls, 1u); + const auto callsBeforeDitch = plugin->ParamSetCalls; + + actions::TriggerAction ditch; + ditch.ActionType = actions::TriggerAction::TRIGGER_DITCH; + ditch.TargetId = take->Id(); + station->OnAction(ditch); + + station->RunAutomationDispatchForTest(halfLen, quarterLen); + EXPECT_EQ(callsBeforeDitch, plugin->ParamSetCalls); + EXPECT_TRUE(take->GetMidiLoops().empty()); +} + TEST(StationAutomation, RecordHeldDoesNotSuppressPlaybackWithoutParameterCooldown) { auto station = MakeStation("station-automation-record"); From 734bc3271ead487e85f4aa28668fb382c1477ae7 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sat, 20 Jun 2026 23:09:23 +0100 Subject: [PATCH 14/27] Move automation record arm to INSERT key global capture --- Jamma/src/Main.cpp | 6 ++ JammaLib/src/engine/Scene.cpp | 18 +++- JammaLib/src/engine/Scene.h | 3 + JammaLib/src/io/IoInputSubsystem.cpp | 69 +++++++++++++++ JammaLib/src/io/IoInputSubsystem.h | 9 ++ JammaLib/src/midi/MidiRouter.cpp | 11 +-- JammaLib/src/midi/MidiRouter.h | 2 +- test/JammaLib_Tests/JammaLib_Tests.vcxproj | 1 + .../JammaLib_Tests.vcxproj.filters | 3 + .../midi/MidiRouterAutomationKey_Tests.cpp | 83 +++++++++++++++++++ 10 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 test/JammaLib_Tests/src/midi/MidiRouterAutomationKey_Tests.cpp diff --git a/Jamma/src/Main.cpp b/Jamma/src/Main.cpp index a447e07a..eb3502a7 100644 --- a/Jamma/src/Main.cpp +++ b/Jamma/src/Main.cpp @@ -378,6 +378,8 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmd if (window.Create(hInstance, nCmdShow) != 0) PostQuitMessage(1); + scene.value()->InitGlobalInsertCapture(); + scene.value()->InitAudio(); MSG msg; @@ -400,6 +402,10 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmd if (!active) break; + actions::KeyAction insertAction; + if (scene.value()->PumpGlobalInsertCapture(insertAction)) + window.OnAction(insertAction); + window.Render(); window.Swap(); diff --git a/JammaLib/src/engine/Scene.cpp b/JammaLib/src/engine/Scene.cpp index edd593f9..2a4bb579 100644 --- a/JammaLib/src/engine/Scene.cpp +++ b/JammaLib/src/engine/Scene.cpp @@ -528,7 +528,7 @@ ActionResult Scene::OnAction(KeyAction action) _networkService->GetController()); } - // Ctrl+Shift+L/W/X/[/]/A - MIDI automation learn, wire, delete, lane cycle, record. + // Insert + Ctrl+Shift+L/W/X/[/] - MIDI automation record, learn, wire, delete, lane cycle. { auto hovered = _ChildFromPath(_selector->CurrentHover()); auto hoveredTake = std::dynamic_pointer_cast(hovered); @@ -855,12 +855,28 @@ void Scene::CloseAudio() _audioEngine->Close(); } +bool Scene::InitGlobalInsertCapture() +{ + return _inputSubsystem->InitGlobalInsertCapture(); +} + +void Scene::CloseGlobalInsertCapture() +{ + _inputSubsystem->CloseGlobalInsertCapture(); +} + +bool Scene::PumpGlobalInsertCapture(actions::KeyAction& action) noexcept +{ + return _inputSubsystem->PumpGlobalInsertCapture(action); +} + void Scene::Shutdown() { _isSceneQuitting.store(true, std::memory_order_release); if (_jobRunner.joinable()) _jobRunner.join(); + CloseGlobalInsertCapture(); CloseAudio(); } diff --git a/JammaLib/src/engine/Scene.h b/JammaLib/src/engine/Scene.h index 3cefb0ca..c4555652 100644 --- a/JammaLib/src/engine/Scene.h +++ b/JammaLib/src/engine/Scene.h @@ -201,6 +201,9 @@ namespace engine void InitGui(); void InitAudio(); void CloseAudio(); + bool InitGlobalInsertCapture(); + void CloseGlobalInsertCapture(); + bool PumpGlobalInsertCapture(actions::KeyAction& action) noexcept; void Shutdown(); void SetLogging(io::LoggingConfig config) noexcept; void InitMidi() diff --git a/JammaLib/src/io/IoInputSubsystem.cpp b/JammaLib/src/io/IoInputSubsystem.cpp index 43d32367..ed436710 100644 --- a/JammaLib/src/io/IoInputSubsystem.cpp +++ b/JammaLib/src/io/IoInputSubsystem.cpp @@ -1,10 +1,16 @@ #include "stdafx.h" #include "IoInputSubsystem.h" +#include + using namespace engine; namespace io { + HHOOK IoInputSubsystem::_globalInsertHook = nullptr; + std::atomic IoInputSubsystem::_globalInsertDown{ false }; + std::atomic IoInputSubsystem::_globalInsertLastDispatchedDown{ false }; + IoInputSubsystem::IoInputSubsystem(io::UserConfig userConfig, io::LoggingConfig loggingConfig) : _userConfig(userConfig), _loggingConfig(loggingConfig) @@ -25,10 +31,73 @@ namespace io void IoInputSubsystem::Close() { + CloseGlobalInsertCapture(); _midiRouter.CloseSerial(); _midiRouter.CloseMidi(); } + bool IoInputSubsystem::InitGlobalInsertCapture() + { + if (_globalInsertHook) + return true; + + const auto down = (GetAsyncKeyState(VK_INSERT) & 0x8000) != 0; + _globalInsertDown.store(down, std::memory_order_release); + _globalInsertLastDispatchedDown.store(down, std::memory_order_release); + + _globalInsertHook = SetWindowsHookEx(WH_KEYBOARD_LL, _LowLevelKeyboardProc, + GetModuleHandle(nullptr), 0); + if (!_globalInsertHook) + { + std::cerr << "[Input] Global insert hook install failed, error=" + << GetLastError() << std::endl; + return false; + } + + return true; + } + + void IoInputSubsystem::CloseGlobalInsertCapture() + { + if (!_globalInsertHook) + return; + + UnhookWindowsHookEx(_globalInsertHook); + _globalInsertHook = nullptr; + } + + bool IoInputSubsystem::PumpGlobalInsertCapture(actions::KeyAction& action) noexcept + { + const auto down = _globalInsertDown.load(std::memory_order_acquire); + const auto last = _globalInsertLastDispatchedDown.load(std::memory_order_acquire); + if (down == last) + return false; + + _globalInsertLastDispatchedDown.store(down, std::memory_order_release); + action.KeyChar = VK_INSERT; + action.KeyActionType = down ? actions::KeyAction::KEY_DOWN : actions::KeyAction::KEY_UP; + action.IsSystem = false; + action.Modifiers = base::Action::MODIFIER_NONE; + return true; + } + + LRESULT CALLBACK IoInputSubsystem::_LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept + { + if (nCode == HC_ACTION) + { + const auto* kb = reinterpret_cast(lParam); + if (kb && kb->vkCode == VK_INSERT) + { + if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN) + _globalInsertDown.store(true, std::memory_order_release); + else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP) + _globalInsertDown.store(false, std::memory_order_release); + } + } + + return CallNextHookEx(_globalInsertHook, nCode, wParam, lParam); + } + IoInputSubsystem::PumpResult IoInputSubsystem::PumpMidi(std::vector>& stations, std::uint64_t audioSampleCounter, const audio::AudioStreamParams& streamParams, diff --git a/JammaLib/src/io/IoInputSubsystem.h b/JammaLib/src/io/IoInputSubsystem.h index 9bd27825..c712db51 100644 --- a/JammaLib/src/io/IoInputSubsystem.h +++ b/JammaLib/src/io/IoInputSubsystem.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "../io/UserConfig.h" #include "../io/SerialDevice.h" #include "../midi/MidiRouter.h" @@ -26,6 +27,9 @@ namespace io void Init(std::atomic& audioSampleCounter, std::atomic& midiAnchorMicros); void Close(); + bool InitGlobalInsertCapture(); + void CloseGlobalInsertCapture(); + bool PumpGlobalInsertCapture(actions::KeyAction& action) noexcept; PumpResult PumpMidi(std::vector>& stations, std::uint64_t audioSampleCounter, @@ -46,6 +50,11 @@ namespace io midi::MidiRouter& GetMidiRouterForTest() { return _midiRouter; } private: + static LRESULT CALLBACK _LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept; + static HHOOK _globalInsertHook; + static std::atomic _globalInsertDown; + static std::atomic _globalInsertLastDispatchedDown; + io::UserConfig _userConfig; io::LoggingConfig _loggingConfig; midi::MidiRouter _midiRouter; diff --git a/JammaLib/src/midi/MidiRouter.cpp b/JammaLib/src/midi/MidiRouter.cpp index 83e77f00..d69ae55a 100644 --- a/JammaLib/src/midi/MidiRouter.cpp +++ b/JammaLib/src/midi/MidiRouter.cpp @@ -67,14 +67,15 @@ actions::ActionResult MidiRouter::HandleAutomationKey(const actions::KeyAction& auto eaten = actions::ActionResult::NoAction(); eaten.IsEaten = true; - if (65 == action.KeyChar) + constexpr unsigned int InsertKey = 45u; + if (InsertKey == action.KeyChar) { - if (isDown && ctrlShift && !_automationRecordKeyHeld) + if (isDown && !_automationRecordKeyHeld) { _automationRecordKeyHeld = true; _ResetEditorTouchStates(); _automationRecordHeld.store(true, std::memory_order_release); - std::cout << ">> Automation record armed (Ctrl+Shift+A) <<" << std::endl; + std::cout << ">> Automation record armed (Insert) <<" << std::endl; return eaten; } if (isUp && _automationRecordKeyHeld) @@ -577,7 +578,7 @@ void MidiRouter::_ConsumeEditorAutomation(const std::vectorActive; if (freshDrag) diff --git a/JammaLib/src/midi/MidiRouter.h b/JammaLib/src/midi/MidiRouter.h index 68fbdc00..04d55421 100644 --- a/JammaLib/src/midi/MidiRouter.h +++ b/JammaLib/src/midi/MidiRouter.h @@ -186,7 +186,7 @@ namespace midi // suppression once; idle pump ticks only age out stale sessions. // // Lifecycle: - // • _ResetEditorTouchStates() clears all states (called on Ctrl+Shift+A press). + // • _ResetEditorTouchStates() clears all states (called on Insert press). // • First VST touch after reset: freshDrag → state activated and a hold window written. // • Subsequent touches in the same record session: state stays active and writes a fresh window. // • No new touch for > cooldown samples: state expires. diff --git a/test/JammaLib_Tests/JammaLib_Tests.vcxproj b/test/JammaLib_Tests/JammaLib_Tests.vcxproj index 3a172cf2..698dc5d3 100644 --- a/test/JammaLib_Tests/JammaLib_Tests.vcxproj +++ b/test/JammaLib_Tests/JammaLib_Tests.vcxproj @@ -187,6 +187,7 @@ xcopy /y "$(VcpkgInstalledDir)$(VcpkgTriplet)\debug\bin\gtest_main.dll" "$(OutDi + diff --git a/test/JammaLib_Tests/JammaLib_Tests.vcxproj.filters b/test/JammaLib_Tests/JammaLib_Tests.vcxproj.filters index 71028299..449e9b30 100644 --- a/test/JammaLib_Tests/JammaLib_Tests.vcxproj.filters +++ b/test/JammaLib_Tests/JammaLib_Tests.vcxproj.filters @@ -101,6 +101,9 @@ src\midi + + src\midi + src\midi diff --git a/test/JammaLib_Tests/src/midi/MidiRouterAutomationKey_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiRouterAutomationKey_Tests.cpp new file mode 100644 index 00000000..2fe3d160 --- /dev/null +++ b/test/JammaLib_Tests/src/midi/MidiRouterAutomationKey_Tests.cpp @@ -0,0 +1,83 @@ +#include "gtest/gtest.h" + +#include +#include + +#include "actions/KeyAction.h" +#include "midi/MidiRouter.h" + +namespace midi_tests +{ + actions::KeyAction BuildKey(unsigned int keyChar, + int keyActionType, + base::Action::Modifiers modifiers = base::Action::MODIFIER_NONE) + { + actions::KeyAction action; + action.KeyChar = keyChar; + action.KeyActionType = (keyActionType == actions::KeyAction::KEY_DOWN) + ? actions::KeyAction::KEY_DOWN + : actions::KeyAction::KEY_UP; + action.IsSystem = false; + action.Modifiers = modifiers; + return action; + } + + class MidiRouterAutomationKeyTestFixture : public ::testing::Test + { + protected: + void SetUp() override + { + midi::MidiRouter::SetAutomationRecordHeldForTest(false); + } + + void TearDown() override + { + midi::MidiRouter::SetAutomationRecordHeldForTest(false); + } + }; + + TEST_F(MidiRouterAutomationKeyTestFixture, InsertPressAndReleaseControlsAutomationRecord) + { + midi::MidiRouter router; + const std::vector> stations; + const std::vector hoverPath; + const std::shared_ptr hoveredTake; + + auto downResult = router.HandleAutomationKey( + BuildKey(45u, actions::KeyAction::KEY_DOWN), + stations, + hoverPath, + hoveredTake); + + EXPECT_TRUE(downResult.IsEaten); + EXPECT_TRUE(midi::MidiRouter::IsAutomationRecordHeld()); + + auto upResult = router.HandleAutomationKey( + BuildKey(45u, actions::KeyAction::KEY_UP), + stations, + hoverPath, + hoveredTake); + + EXPECT_TRUE(upResult.IsEaten); + EXPECT_FALSE(midi::MidiRouter::IsAutomationRecordHeld()); + } + + TEST_F(MidiRouterAutomationKeyTestFixture, CtrlShiftADoesNotArmAutomationRecord) + { + midi::MidiRouter router; + const std::vector> stations; + const std::vector hoverPath; + const std::shared_ptr hoveredTake; + + auto oldShortcutResult = router.HandleAutomationKey( + BuildKey(65u, + actions::KeyAction::KEY_DOWN, + static_cast(base::Action::MODIFIER_CTRL | base::Action::MODIFIER_SHIFT)), + stations, + hoverPath, + hoveredTake); + + EXPECT_FALSE(oldShortcutResult.IsEaten); + EXPECT_FALSE(midi::MidiRouter::IsAutomationRecordHeld()); + } +} From b0c8c20ba6ce1ca12e350e4a3194096fed40dfcc Mon Sep 17 00:00:00 2001 From: malisimat Date: Sat, 20 Jun 2026 23:10:22 +0100 Subject: [PATCH 15/27] Remove dev artefacts --- .../plan-midiAutomationRecording.prompt.md | 403 ------------------ doc/automation-agent-brief.md | 218 ---------- doc/automation-agent-issues.md | 47 -- doc/vst-editor-automation-handoff.md | 95 ----- 4 files changed, 763 deletions(-) delete mode 100644 .vscode/plan-midiAutomationRecording.prompt.md delete mode 100644 doc/automation-agent-brief.md delete mode 100644 doc/automation-agent-issues.md delete mode 100644 doc/vst-editor-automation-handoff.md diff --git a/.vscode/plan-midiAutomationRecording.prompt.md b/.vscode/plan-midiAutomationRecording.prompt.md deleted file mode 100644 index cf448ec7..00000000 --- a/.vscode/plan-midiAutomationRecording.prompt.md +++ /dev/null @@ -1,403 +0,0 @@ -# MIDI Automation Recording, 3D Display, and VST Instrument Playback Plan - -This plan outlines the design and implementation details for adding low-latency MIDI automation recording and playback to JammaLib, accompanied by a dynamic, undulating 3D ring visualizer. - -Coding rule for this plan: constants and helper functions must be attached to the owning class or namespace, not dropped into anonymous namespaces. Prefer named class members, static class helpers, or constexpr class-scoped values over file-local anonymous helpers. - ---- - -## Agentic Workflow - -This plan is designed to be executed in **two agent sessions** with a clean engine/rendering split. - -### Session 1 — Engine Backend (Phases 1–4) - -**Covers:** VST interface, MidiLoop data model, MIDI learn + keyboard arming, audio-thread dispatch. - -**Implement strictly in order:** -1. **Phase 1** — VST interface + registry. Build clean before moving on. -2. **Phase 2** — `MidiLoop` automation data model. Data structures and interpolation only; no rendering. -3. **Phase 3** — Keyboard arming and MIDI learn hooks routed via `IoInputSubsystem`/`MidiRouter` (Scene delegates only). -4. **Phase 4** — Flat dispatch list, `_RebuildAutomationDispatch`, `_RunVstBlock` loop. - -**Stop when:** -- `JammaLib` and `Jamma` build clean with no warnings introduced. -- A CC knob move during `A`-held recording writes control points into `MidiLoop::_lanes`. -- `_RebuildAutomationDispatch` populates the flat list and `_RunVstBlock` calls `SetParameter` for at least one active lane (verified by a GTest or a scoped debug trace — no rendering required). - -**Handover:** Prepend `[DONE]` to each completed section heading (e.g. `### [DONE] 1-A: ...`). If the session ends mid-phase, mark the incomplete section `[IN PROGRESS]` and add a `> **Agent note:**` blockquote immediately below it describing exactly what was finished, what file/line to resume from, and any invariants that must hold before continuing. Leave Phases 5–6 headings untouched. - ---- - -### Session 2 — Multi-Lane + Rendering (Phases 5–6) - -**Prerequisite:** Session 1 complete. Flat dispatch list is live; `SetParameter` is being called correctly. - -**Implement in order:** -1. **Phase 5** — Confirm and harden `MaxAutomationLanes` behavior for multi-lane UX/policy (same-parameter conflicts, lane highlighting, deterministic precedence). -2. **Phase 6** — `automation.vert` / `automation.frag` shaders, `MidiModel` VAO/VBO setup, live control-point texture uploads. - -**Stop when:** -- Both shaders compile and link. -- `MidiModel::Draw3d` renders the curtain, crown ring, and play-position indicator. -- Live recording visibly undulates the curtain in real time. - -**Handover:** Mark all completed sections `[DONE]`. Apply the same `[IN PROGRESS]` + blockquote convention for any work left mid-section. - ---- - -## Architecture Overview - -```mermaid -graph TD - classDef main fill:#2277ff,stroke:#fff,stroke-width:2px,color:#fff; - classDef subt fill:#114499,stroke:#fff,color:#fff; - classDef nonrt fill:#1a5c1a,stroke:#aaffaa,color:#fff; - - subgraph User Input - K[Keyboard Key Events] -->|L, W, A, X, brackets| SC[Scene::OnAction] - SC -->|delegate| IO[IoInputSubsystem::HandleAutomationKey] - IO -->|automation command handling| MR - E[Mouse / VST UI GUI] -->|SetParameterAutomated| V2[audioMasterAutomate Callback] - M[MIDI Keyboard/Controller] -->|Physical CC Ingress| MR[MidiRouter::PumpMidi] - end - - subgraph State & Registry - V2 -->|Trapped Parameter| TR[vst::LastTouchedRegistry] - TR -->|Linked on Key W| ML[midi::MidiLoop AutomationRing] - MR -->|CC Msg & RecordHeld| ML - end - - subgraph Non-Audio Thread Maintenance - MR -->|Wire / Delete / Release A| RB[Station::_RebuildAutomationDispatch] - ML -->|Resolve raw ptrs & lane metadata| RB - RB -->|atomic swap release| AD[_automationDispatch flat list] - end - - subgraph Audio Thread Player - AD -->|acquire load, cursor advance| ST[Station::_RunVstBlock] - ST -->|SetParameter delta-gated| VC[vst::VstChain / Instrument] - end - - subgraph Rendering Pipeline - ML -->|Read control points| MM[graphics::MidiModel] - MM -->|Upload sparse texture| VS[automation.vert / automation.frag] - VS -->|Undulating Circular Curtain, Crown Ring, Play-Position| SCR[Screen View] - end - - class K,E,M main; - class SC,V2,MR,TR,ML,ST,VC,MM,VS,SCR subt; - class RB,AD nonrt; -``` - ---- - -## Phase 1: Track Last Touched Parameter - -### [DONE] 1-A: Interface Extension `IVstPlugin` -- Modify `IVstPlugin` in [JammaLib/src/vst/IVstPlugin.h](JammaLib/src/vst/IVstPlugin.h) to expose parameter setters: - ```cpp - virtual void SetParameter(unsigned int index, float value) noexcept = 0; - virtual float GetParameter(unsigned int index) const noexcept = 0; - ``` - -### [DONE] 1-B: VST2 Plugin Implementation -- Implement the methods in [JammaLib/src/vst/Vst2Plugin.h](JammaLib/src/vst/Vst2Plugin.h) and [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp): - ```cpp - void Vst2Plugin::SetParameter(unsigned int index, float value) noexcept { - if (_effect) { - _effect->setParameter(_effect, index, value); - } - } - float Vst2Plugin::GetParameter(unsigned int index) const noexcept { - return _effect ? _effect->getParameter(_effect, index) : 0.0f; - } - ``` -- Implement empty stubs in [JammaLib/src/vst/Vst3Plugin.cpp](JammaLib/src/vst/Vst3Plugin.cpp) to satisfy the compiler interface checks. - -### [DONE] 1-C: Last Touched Registry -- Add a thread-safe registry in `vst` namespace inside [JammaLib/src/vst/IVstPlugin.h](JammaLib/src/vst/IVstPlugin.h): - ```cpp - struct LastTouchedParameter { - std::atomic Plugin{nullptr}; - std::atomic ParameterIndex{0u}; - std::atomic Value{0.0f}; - }; - extern LastTouchedParameter _lastTouchedParam; - ``` -- Update `HostCallback` in [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp)'s `audioMasterAutomate` case: - ```cpp - case audioMasterAutomate: - if (self) { - _lastTouchedParam.Plugin.store(self, std::memory_order_relaxed); - _lastTouchedParam.ParameterIndex.store(index, std::memory_order_relaxed); - _lastTouchedParam.Value.store(opt, std::memory_order_relaxed); - } - return 0; - ``` - ---- - -## Phase 2: In-Memory Automation Timeline inside `MidiLoop` - -### [DONE] 2-A: Automation Timeline Representation -- Represent the automation as sparse control points in the loop timeline and upload them to the GPU as an $N \times 2$ lookup texture, where each texel stores: - - `R`/`x`: the automation value - - `G`/`y`: the fractional position through the loop where that value applies -- Each `MidiLoop` holds a fixed-size array of **automation lanes** (`MaxAutomationLanes = 8`). Each lane is self-contained: it owns its `AutomationMapping` metadata, its sparse control-point buffer, and its point count. This directly supports Phase 5 (multiple wirings, simultaneous recording) without any structural change later. -- In [JammaLib/src/midi/MidiLoop.h](JammaLib/src/midi/MidiLoop.h), establish: - ```cpp - struct AutomationMapping { - // Active, Channel, and CC must be read atomically together on the MIDI thread - // (CC matching) while written together on the UI thread (key W). Pack all three - // into one uint32_t so a reader always sees a consistent triple. - // Encoding: bit 16 = Active, bits [15:8] = Channel, bits [7:0] = CC; 0 = inactive. - std::atomic MatchKey{0u}; - - // Written and read on the non-audio thread only (_RebuildAutomationDispatch). - // No atomic needed. - vst::IVstPlugin* TargetPlugin{nullptr}; - unsigned int TargetParameterIndex{0u}; - - static constexpr std::uint32_t kInactive = 0u; - static constexpr std::uint32_t MakeMatchKey(std::uint8_t ch, std::uint8_t cc) noexcept { - return (1u << 16) | (static_cast(ch) << 8) | static_cast(cc); - } - bool IsActive() const noexcept { return (MatchKey.load(std::memory_order_relaxed) >> 16) & 1u; } - std::uint8_t GetChannel() const noexcept { return static_cast(MatchKey.load(std::memory_order_relaxed) >> 8); } - std::uint8_t GetCC() const noexcept { return static_cast(MatchKey.load(std::memory_order_relaxed)); } - }; - - struct AutomationLane { - AutomationMapping Mapping; - std::array, 256u> Points{}; // (frac, value) - std::size_t PointCount = 0u; - }; - - class MidiLoop { - public: - static constexpr std::size_t MaxAutomationLanes = 8u; - - // Write a value at the given fractional position into lane laneIdx. - void SetAutomationValueAtFrac(std::size_t laneIdx, double frac, float value) noexcept; - // Cursor-advancing read on lane laneIdx: advances cursorIdx forward to the correct - // bracket, returns the linearly interpolated value. Resets cursor on loop wrap - // (detected when frac < points[cursor].frac). Amortised O(1) per block. - float GetAutomationValueAtCursor(std::size_t laneIdx, double frac, std::uint16_t& cursorIdx) const noexcept; - - AutomationLane& GetLane(std::size_t idx) noexcept { return _lanes[idx]; } - const AutomationLane& GetLane(std::size_t idx) const noexcept { return _lanes[idx]; } - - private: - std::array _lanes{}; - }; - ``` -- The CPU keeps each lane's sparse points in their native form; the GPU receives a lane's points as a compact 2-channel texture for display. Each lane uploads independently. - -### [DONE] 2-B: Read/Write Interpolation Implementation -- Inside [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp): - - Preserve sparse points exactly as they are recorded. - - When a new point arrives, insert or update the point in the loop-local control-point storage. - - For audio playback, continue to evaluate the curve via linear interpolation between neighboring control points. - - For display, upload the control-point texture to the GPU and let the shader perform piecewise-linear lookup against the sparse timeline data. - ---- - -## Phase 3: Keyboard Arming and MIDI Learn Hooks - -### [DONE] 3-A: Interactive State Flags -- Add automation interaction state inside [JammaLib/src/midi/MidiRouter.h](JammaLib/src/midi/MidiRouter.h) as `MidiRouter` members (not namespace globals): - ```cpp - std::atomic _learnMidiCCMode{ false }; - std::atomic _learnedCC{ LearnNothingCaptured }; - std::atomic _learnedChannel{ LearnNothingCaptured }; - std::atomic _selectedLaneIndex{ 0u }; - bool _automationRecordKeyHeld = false; - static std::atomic _automationRecordHeld; - ``` -- `_learnedCC` and `_learnedChannel` are written inside `MidiRouter::PumpMidi` whenever a CC message arrives and `_learnMidiCCMode` is true. -- `_automationRecordHeld` is exposed through `MidiRouter::IsAutomationRecordHeld()` and test-only setter `MidiRouter::SetAutomationRecordHeldForTest(bool)`. - -### [DONE] 3-B: Interactive Key Bindings -- Scene now delegates automation key handling through `IoInputSubsystem::HandleAutomationKey(...)`, which forwards to `MidiRouter::HandleAutomationKey(...)`. - - Key `L` — **Learn Mode** (on key-down): Toggle `_learnMidiCCMode`. When toggling off, reset `_learnedCC` and `_learnedChannel` to `0xffu`. - - Key `W` — **Wire Command** (on key-down): - - Requires `_learnedCC != 0xffu` and `vst::_lastTouchedParam.Plugin != nullptr`. - - Resolves hovered automation target from station hover path + hovered LoopTake fallback. - - Writes to `loop.GetLane(_selectedLaneIndex)` and rebuilds the owning station dispatch. - - Exits learn mode and clears capture. - - Key `X` — **Delete Lane** (on key-down): Clears `loop.GetLane(_selectedLaneIndex)` on hovered loop and rebuilds that station dispatch. - - Key `[` / `]` — **Cycle Selected Lane** (on key-down): updates `_selectedLaneIndex` with wraparound. - - Key `A` — **Automation Record Mode**: sets `_automationRecordHeld = true` on key-down and false on key-up; key-up rebuilds dispatch for all local stations. Recording is keyed by lane mapping match (`AutomationMapping::MatchKey`), not by a single record-target loop pointer. - ---- - -## Phase 4: Audio Thread Playback Routing - -### [DONE] 4-A: Pre-baked Flat Dispatch List (eliminates nested weak_ptr::lock chains) - -The naïve approach of walking `state.LoopTakes → weak_ptr::lock → GetMidiLoops → GetAutomation` inside `_RunVstBlock` pays O(takes × midiLoops) in atomic refcount increments, shared_ptr pointer chasing, and per-entry atomic loads — every audio block, at audio-thread priority. This is unacceptable. - -Instead, build a compact flat list on the **non-audio thread** whenever automation wiring changes, and publish it with the same double-buffer atomic-swap pattern already used for `_audioState`. - -Add to [JammaLib/src/engine/Station.h](JammaLib/src/engine/Station.h): -```cpp -struct AutomationDispatch { - vst::IVstPlugin* plugin; // raw observer — lifetime owned by VstChain - unsigned int paramIdx; - midi::MidiLoop* loop; // raw observer — lifetime owned by LoopTake - std::uint8_t laneIdx; // which lane within loop to read/write - std::uint32_t loopLengthSamps; // pre-resolved; avoids per-block takes lock - std::uint16_t cursorIdx = 0u; // playback cursor for O(1) amortised interpolation - float lastValue = -2.f; // sentinel: force first write -}; -static constexpr std::size_t MaxAutomationDispatches = 64u; - -std::atomic _automationDispatch{nullptr}; -AutomationDispatch _automationDispatchBuf[2][MaxAutomationDispatches]{}; -std::uint8_t _automationDispatchCount[2]{}; -std::uint8_t _automationDispatchBack = 0u; -``` - -Add `_RebuildAutomationDispatch()` — called on the non-audio thread whenever automation is wired or the loop set changes. It walks all takes and MIDI loops, resolves all raw pointers, and atomically publishes the new front buffer. This is the only place that traverses weak_ptrs and shared_ptrs for automation purposes. - -### [DONE] 4-B: Per-loop `frac` from `blockStartSample` (not from the clock) - -`_clock->FractionalPosition()` would use `SeedSourceLength()` as the denominator — correct only for the seed loop. Overdub loops may be harmonically related but still have independent lengths. Each dispatch entry stores `loopLengthSamps` pre-resolved at build time. Per-block fractional position: - -```cpp -const double frac = (entry.loopLengthSamps > 0u) - ? std::fmod(static_cast(blockStartSample), static_cast(entry.loopLengthSamps)) - / static_cast(entry.loopLengthSamps) - : 0.0; -``` - -This is correct for all loop configurations and costs one `fmod` instead of an indirect clock method call. - -### [DONE] 4-C: Playback cursor — O(1) amortised interpolation (replaces O(N) scan) - -`GetAutomationValueAtFrac` with up to 256 sparse control points costs up to 256 comparisons per active mapping per block if searching from index 0. Since playback is monotonically forward (wrapping only at loop boundaries), the bracket for the current `frac` advances by at most 1–2 control points per block in normal use. - -Store `cursorIdx` in each `AutomationDispatch`. Per block: -1. Advance cursor forward while `points[cursor+1].frac <= frac` (typically 0–2 iterations). -2. Interpolate linearly between `points[cursor]` and `points[cursor+1]`. -3. On loop wrap (detected when `frac < lastFrac`), reset cursor to 0. - -Amortised cost across a full loop playback: O(total control points) — equivalent to a single pass, not one pass per block. - -### [DONE] 4-D: Delta-threshold gate — suppress redundant `SetParameter` calls - -VST2 `setParameter` is an opcode dispatch that can trigger coefficient recalculation inside the plugin on every call. On a flat or slow-moving automation curve (the common case), the value barely changes between consecutive blocks. Gate the call: - -```cpp -constexpr float automationEpsilon = 1.0f / 65536.0f; // below 16-bit parameter resolution -if (std::abs(val - entry.lastValue) > automationEpsilon) { - entry.plugin->SetParameter(entry.paramIdx, val); - entry.lastValue = val; -} -``` - -On a steady-state loop after the first pass, this reduces `SetParameter` calls to zero — the dominant cost on a playing-but-not-moving automation lane. - -### [DONE] 4-E: Final audio-thread dispatch loop - -> **Agent note:** Implemented as `Station::_RunAutomationDispatch(blockStartSample)`, called from `Station::WriteBlock` immediately after `_RunVstBlock`. It is deliberately *not* gated behind `vstActive`, so recorded parameter motion still drives a bypassed/idle chain. Buffer-index derivation, acquire/release fencing, per-loop `fmod`, cursor advance, and the delta gate (`AutomationEpsilon = 1/65536`) match the plan. Verified by `StationAutomation.*` GTests in `test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp`. Test hook: `Station::RunAutomationDispatchForTest`. - -With the above in place, the audio-thread path collapses to: - -```cpp -const bool recordHeld = midi::MidiRouter::IsAutomationRecordHeld(); -const auto* dispatches = _automationDispatch.load(std::memory_order_acquire); -// Derive front buffer index from which half of _automationDispatchBuf the pointer falls in. -const std::uint8_t frontIdx = dispatches ? (dispatches == _automationDispatchBuf[0] ? 0u : 1u) : 0u; -const auto count = dispatches ? _automationDispatchCount[frontIdx] : 0u; - -for (auto i = 0u; i < count; ++i) -{ - auto& entry = dispatches[i]; - // Suppress playback while automation recording is held, so incoming CC writes - // own parameter movement without playback tug-of-war. - if (recordHeld) continue; - - // Per-loop fractional position from block-start sample. - const double frac = (entry.loopLengthSamps > 0u) - ? std::fmod(static_cast(blockStartSample), - static_cast(entry.loopLengthSamps)) - / static_cast(entry.loopLengthSamps) - : 0.0; - - // Advance cursor forward (0–2 steps amortised); laneIdx pre-baked into entry. - const float val = entry.loop->GetAutomationValueAtCursor(entry.laneIdx, frac, entry.cursorIdx); - - // Only call into the plugin if the value actually moved. - constexpr float epsilon = 1.0f / 65536.0f; - if (std::abs(val - entry.lastValue) > epsilon) - { - entry.plugin->SetParameter(entry.paramIdx, val); - entry.lastValue = val; - } -} -``` - -No `weak_ptr::lock`. No shared_ptr deref chain. No per-entry atomic loads. One flat loop over a hot cache line. - ---- - -## Phase 5: Multiple Automation Parameters and Multi-Playback - -### [DONE] 5-A: Per-Parameter Automation State -- `MidiLoop` already holds a fixed array of `AutomationLane` (capacity `MaxAutomationLanes = 8`, defined in Phase 2-A). Each lane is fully self-contained: its own `AutomationMapping` metadata, its own sparse control-point buffer, and its own point count. No structural changes to `MidiLoop` are required for multi-lane support. -- `_selectedLaneIndex` identifies which slot the user is currently operating on. -- Recording is now mapping-driven across all local stations while `_automationRecordHeld` is true; there is no single `RecordTargetLoop` pointer. -- This allows: - - simultaneous recording of several parameters from the same loop, - - simultaneous recording of mapped parameters across multiple stations, - - independent wiring of different CCs/controllers to different parameters (each lane stores its own `ControllerNumber`/`Channel`), - - and independent playback of each mapped parameter via the flat dispatch list. - -### [DONE] 5-B: Simultaneous / Independent Recording -- When recording is active, incoming MIDI CC values are routed to every active mapping with matching `(channel, cc)` across all local stations and their MIDI loops. -- A single loop may record multiple automation lanes at once if multiple mappings match. -- Multiple stations may record at the same time if they contain matching active mappings. -- Each mapping should keep its own timeline of sparse control points, so one parameter’s curve can evolve independently of another’s. -- The UI/interaction layer should make it clear which automation lane is currently being learned or recorded. - -### [DONE] 5-C: Multi-Playback Routing -- During playback, evaluate all active automation mappings for a loop and apply each mapped value to its target plugin parameter. -- Playback should be additive/independent per mapping, not a single shared curve. -- If multiple automation mappings target the same plugin parameter, the system should either: - - apply them in deterministic order, or - - define a clear precedence rule such as “last wired mapping wins” or “multi-lane blend.” -- The initial implementation should favor deterministic, easy-to-reason-about behavior: evaluate mappings in insertion order and apply each to its target parameter. - -### [DONE] 5-D: Rendering Multiple Automation Curves -- The display should support rendering multiple automation lanes for the same loop, each with its own texture-backed curve. -- One vertex shader uniform (Radius) and one fragment shader uniform (Color) are used to distinguish different parameters/lanes in the 3D visualization. -- The shader path should remain generic: one automation curve texture can drive one visual band/ribbon, and multiple bands can be drawn for multiple mapped parameters. - ---- - -## Phase 6: Holographic 3D Undulating Display - -### [DONE] 6-A: Pipeline Shader Modifications - -> **Agent note:** Implemented with a `uniform vec2 AutoPoints[256]` array instead of an N×2 lookup *texture*. Functionally equivalent (piecewise-linear interp in the vertex shader), but avoids a new `TextureResource` subclass and per-frame `glTexImage2D` uploads. Control points are snapshotted from the live `MidiLoop` lane each draw via a seqlock reader (`SnapshotAutomationLanePoints`) and pushed as uniforms. Single shader pair handles all four primitives via an `int RenderMode` uniform (0 curtain / 1 crown / 2 playhead / 3 dot). - -- Store shader pipeline configs [Jamma/resources/shaders/automation.vert](Jamma/resources/shaders/automation.vert) and [Jamma/resources/shaders/automation.frag](Jamma/resources/shaders/automation.frag): - - **Vertex Shader**: Use the automation lookup texture to sample the curve by the vertex’s loop position, then offset the geometry along the circular trajectory using the sampled height value. - - **Fragment Shader**: Apply a color gradient (circumferentially, based on uv coordinates, so color and alpha fade with loop time, brightest at current play position). Also feature bright highlighted thick top edge and vertical play position. Should brighten when recording is active (frag uniform). -- The vertex shader should perform piecewise-linear interpolation across the sparse control points stored in the $N \times 2$ texture (first coord is time [0:1], second coord is automation height value), so the display updates live while recording without CPU resampling. - -### [DONE] 6-B: VAO Setup inside `MidiModel` - -> **Agent note:** Four static VAO/VBO pairs built once in `_InitAutomationGl` (curtain triangle-strip, crown line-loop, playhead line, dot point). `MidiModel` caches a `const midi::MidiLoop*` back-pointer set in `MidiLoop::AttachModel`. Per-lane draw loop in `_DrawAutomation` snapshots points, sets per-lane radius/height/colour uniforms, and issues the four draws. No mesh regeneration per frame; only uniforms change. "Texture re-upload on change" requirement is satisfied by the per-draw uniform snapshot. - -- Update [JammaLib/src/graphics/MidiModel.h](JammaLib/src/graphics/MidiModel.h) and [JammaLib/src/graphics/MidiModel.cpp](JammaLib/src/graphics/MidiModel.cpp): - - Cache a reference pointer to `midi::MidiLoop` in `MidiModel`. - - Build a fixed mesh once for the automation display, with the geometry defined in a reusable VAO/VBO pair. UV's normalised to the loop length / full arc. The main mesh should be a circular curtain with a small vertical height (scaled per LoopTake height), and the vertices should be arranged in a triangle strip around the circumference. - - The glowing ring crown should be a separate circular line loop drawn above the curtain (same VBO/VAO, different shaders). - - The vertical showing playposition should be a thin line drawn on top of the curtain, staying facing the camera fixed (play position is always facing forward). - - A bright dot should also be drawn at the current play position on the curtain, with a small halo glow (distinct fragment shader, simple vertex shader - just set vertical position in draw3d as a translation matrix multipled with MVP and not use the automation lookup texture). - - Keep the meshes static across draws; the vertex shader should deform them using the current automation lookup texture and the per-vertex loop position (except for dot, which gets set by MVP transform). - - Use `GL_TRIANGLE_STRIP` for the undulating 3D curtain and `GL_LINE_LOOP` for the glowing ring crown. - - Re-upload the automation texture whenever the control-point set changes during recording so the display updates immediately, but do not regenerate the mesh on every `Draw3d` call. diff --git a/doc/automation-agent-brief.md b/doc/automation-agent-brief.md deleted file mode 100644 index fa60cde8..00000000 --- a/doc/automation-agent-brief.md +++ /dev/null @@ -1,218 +0,0 @@ -# Jamma Automation Agent Brief - -Purpose: concise orientation for agents touching automation recording/playback/visualization. - -Related deep dive: [doc/automation-deep-dive.html](doc/automation-deep-dive.html) - -## Scope - -This covers: -- Editor-driven automation capture (VST param drags) -- CC-driven lane writes -- Audio-thread playback dispatch back to VST params -- Automation visuals (play position, rotation, alpha trail) -- Thread ownership and synchronization assumptions - ---- - -## 1) End-to-End Flow (Editor Drag -> Lane -> VST) - -1. VST2 editor touch triggers `audioMasterAutomate` in the host callback. - - Host publishes the touched tuple: plugin pointer, parameter index, value, sequence. - - Host does not echo the value back through `setParameter`; the plugin already updated its own parameter state before notifying the host. - - refs: [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp), [JammaLib/src/vst/IVstPlugin.h](JammaLib/src/vst/IVstPlugin.h) -2. Job-thread MIDI pump runs periodically and consumes touched tuples only while automation hold is armed. - - The pump advances the last-seen sequence even while disarmed, so stale pre-arm touches are not replayed when the user arms later. - - refs: [JammaLib/src/engine/Scene.cpp](JammaLib/src/engine/Scene.cpp), [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) -3. On each fresh touch sequence, the recorder resolves the target loop/lane, wires mapping if needed, computes the current loop sample, and overwrites a bounded future window with a held value. - - Current implementation writes a point at the touch position and another at `touch + cooldown`, removing points that fall inside that window. - - No lane points are written on idle pump cycles. - - refs: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp), [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) -4. Non-audio thread rebuilds flat automation dispatch entries when wiring/topology changes. - - ref: [JammaLib/src/engine/Station.cpp](JammaLib/src/engine/Station.cpp) -5. Audio callback reads the dispatch list, samples the lane at the latest sample in the block, and calls `SetParameter` at most once per mapped parameter per block. - - Playback still uses per-parameter suppression and value delta gating. - - refs: [JammaLib/src/engine/Station.cpp](JammaLib/src/engine/Station.cpp), [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) - ---- - -## 2) Arming and State Landmarks - -- Ctrl+Shift+A down: - - sets record hold true - - resets editor overwrite sessions - - refs: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) -- First touch after arm/expiry: - - starts a fresh cooldown session for that plugin+param - - writes one bounded hold window immediately -- No touch for cooldown window: - - touch state expires -- Ctrl+Shift+A up: - - disables hold and rebuilds dispatch - - refs: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) - -Key distinction: -- Arm instant != record-start instant. -- Record starts on first consumed touch sequence after arm. -- Current implementation intentionally keeps the existing arm gesture; editor touches do not auto-arm recording. - ---- - -## 3) Overwrite Semantics (Actual Current Behavior) - -Current implementation is event-driven bounded overwrite, not progressive sweep and not hard whole-lane wipe: -- A fresh editor touch rewrites the half-open window `[touchSample, touchSample + cooldown)` with a held value. -- The implementation removes existing points inside that window, then writes one point at the window start and one point at the window end. -- Wrapped windows split naturally across the loop boundary. -- Idle PumpMidi cycles do not add points. -- Point insertion still merges near-equal fractional positions with epsilon `1 / 2048`. - -Refs: -- touch handling: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) -- window overwrite helper: [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) -- point write/merge: [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) - -Important correction: -- Header and implementation comments now describe cooldown-window overwrite semantics rather than a sweeping write-head. - ---- - -## 4) Playback Control and Suppression - -Audio-thread dispatch behavior: -- Reads pre-baked dispatch list -- Skips entries under per-parameter suppression window -- Computes frac from the latest sample in the current block and loop anchor -- Interpolates lane value with cursor progression -- Calls SetParameter only if delta exceeds AutomationEpsilon - -Refs: -- dispatch loop: [JammaLib/src/engine/Station.cpp](JammaLib/src/engine/Station.cpp) -- suppression table check/update: [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) - -Suppression semantics: -- Refresh happens only on actual received editor changes. -- Idle pump cycles do not keep suppression alive. -- Expiry is still held in the sample domain, not wall-clock time. - -Plugin support detail: -- VST2 `audioMasterAutomate` now publishes the touch without host-side parameter echo: [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp) -- VST2 playback SetParameter is wired: [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp) -- VST3 SetParameter currently no-op in host: [JammaLib/src/vst/Vst3Plugin.cpp](JammaLib/src/vst/Vst3Plugin.cpp) - ---- - -## 5) Time/Phase Math You Must Preserve - -Core frac equation shape (recorder + playback): - -frac = fmod(globalSample - loopPhaseAnchor, loopLen) / loopLen - -Phase anchor is set when MIDI loop recording ends so play index and loop position align. -- ref: [JammaLib/src/engine/LoopTake.cpp](JammaLib/src/engine/LoopTake.cpp) - -Recorder note: -- Editor-touch capture converts `globalSampleNow` to loop-sample space first, then rewrites the cooldown window in sample domain. - -Visual play fraction uses loop/take index logic and is pushed into midi models each draw tick. -- refs: [JammaLib/src/engine/LoopTake.cpp](JammaLib/src/engine/LoopTake.cpp) - ---- - -## 6) Visual Automation Quick Map - -- Model rotation angle: TWOPI * loopIndexFrac - - ref: [JammaLib/src/graphics/MidiModel.cpp#L136](JammaLib/src/graphics/MidiModel.cpp#L136) -- Play position fed to automation shader as PlayFrac - - ref: [JammaLib/src/graphics/MidiModel.cpp#L415](JammaLib/src/graphics/MidiModel.cpp#L415) -- Lane geometry sampled from sparse points (piecewise linear) - - ref: [Jamma/resources/shaders/automation.vert#L26](Jamma/resources/shaders/automation.vert#L26) -- Alpha/brightness trail around playhead via wrapped distance - - ref: [Jamma/resources/shaders/automation.frag#L18](Jamma/resources/shaders/automation.frag#L18) - ---- - -## 7) Thread Ownership (Do Not Blur) - -Audio callback thread (RT): -- station WriteBlock, automation dispatch playback, plugin parameter writes -- refs: [JammaLib/src/audio/AudioHost.cpp#L107](JammaLib/src/audio/AudioHost.cpp#L107), [JammaLib/src/engine/Station.cpp#L340](JammaLib/src/engine/Station.cpp#L340) - -Job thread: -- pump midi/serial, consume editor touches, refresh suppression -- refs: [JammaLib/src/engine/Scene.cpp#L1229](JammaLib/src/engine/Scene.cpp#L1229), [JammaLib/src/io/IoInputSubsystem.cpp#L32](JammaLib/src/io/IoInputSubsystem.cpp#L32) - -UI thread: -- key handling for arm/wire/clear/lane controls -- ref: [JammaLib/src/engine/Scene.cpp#L470](JammaLib/src/engine/Scene.cpp#L470) - -Render thread: -- model rotation push and automation draw/snapshot -- refs: [JammaLib/src/engine/LoopTake.cpp#L305](JammaLib/src/engine/LoopTake.cpp#L305), [JammaLib/src/graphics/MidiModel.cpp#L389](JammaLib/src/graphics/MidiModel.cpp#L389) - ---- - -## 8) Safe Change Checklist for Agents - -Before change: -- Confirm if change touches recorder, dispatcher, visuals, or all three. -- Verify thread context for every modified function. - -If modifying overwrite behavior: -- Decide explicitly: bounded overwrite window vs whole-lane wipe. -- Keep docs/comments aligned across h/cpp. -- Validate lane point count and merge behavior under sustained writes. -- Validate wrapped overwrite windows across the loop boundary. - -If modifying playback: -- Preserve suppression semantics and sample-domain comparisons. -- Preserve latest-in-block sampling unless deliberately changing timing behavior. -- Keep dispatch loop flat and RT-safe. -- Re-check VST2/VST3 behavior expectations. - -If modifying visuals: -- Keep playFrac source consistent with LoopIndexFrac path. -- Validate alpha trail behavior around wrap boundary. - -After change: -- Re-read all touched comments for semantic drift. -- Run relevant tests/builds and do a quick manual reasoning pass for race windows. - ---- - -## 9) Remaining Items / Known Gaps - -- VST3 parameter playback is still not implemented in the host. Editor/playback behavior described here is only fully true for VST2 today. -- Automation lane storage is still fixed-capacity (`2048` points per lane). The new editor-touch path avoids the old runaway PumpMidi writes, but the storage is not truly unbounded. -- Full-lane or dynamic-storage redesign has not been done. If dense automation still approaches the cap in real sessions, this needs a second pass. -- `AutomationEpsilon` in playback still exists as a delta gate. It is now far less likely to distort timing because playback samples block-end, but the policy is still conservative rather than eliminated. -- External CC-driven suppression refresh was left alone. The suppression fixes here are for editor-origin automation changes. -- `doc/automation-deep-dive.html` may still describe the old progressive overwrite model until separately refreshed. - ---- - -## 10) Decisions To Confirm With Matt - -- The existing Ctrl+Shift+A arm gesture was preserved. Editor touches do not auto-start recording. -- The VST2 host callback only publishes `audioMasterAutomate`; it does not echo the value back via `setParameter`. -- Playback now samples the latest sample in the block, not block start. -- The fix chose bounded overwrite windows over progressive sweep and over full-lane wipe. -- The fix kept the current fixed lane-point storage for now instead of widening scope into a larger container redesign. - -If any of those decisions are wrong for the product direction, revisit them before expanding the automation work further. - ---- - -## 11) Primary Files - -- [JammaLib/src/midi/MidiRouter.cpp](JammaLib/src/midi/MidiRouter.cpp) -- [JammaLib/src/midi/MidiRouter.h](JammaLib/src/midi/MidiRouter.h) -- [JammaLib/src/midi/MidiLoop.cpp](JammaLib/src/midi/MidiLoop.cpp) -- [JammaLib/src/midi/MidiLoop.h](JammaLib/src/midi/MidiLoop.h) -- [JammaLib/src/engine/Station.cpp](JammaLib/src/engine/Station.cpp) -- [JammaLib/src/engine/LoopTake.cpp](JammaLib/src/engine/LoopTake.cpp) -- [JammaLib/src/graphics/MidiModel.cpp](JammaLib/src/graphics/MidiModel.cpp) -- [Jamma/resources/shaders/automation.vert](Jamma/resources/shaders/automation.vert) -- [Jamma/resources/shaders/automation.frag](Jamma/resources/shaders/automation.frag) -- [JammaLib/src/vst/Vst2Plugin.cpp](JammaLib/src/vst/Vst2Plugin.cpp) -- [JammaLib/src/vst/Vst3Plugin.cpp](JammaLib/src/vst/Vst3Plugin.cpp) diff --git a/doc/automation-agent-issues.md b/doc/automation-agent-issues.md deleted file mode 100644 index 171d3b22..00000000 --- a/doc/automation-agent-issues.md +++ /dev/null @@ -1,47 +0,0 @@ -# MIDI Automation - Review of Implementation - -This doc notes a number of issues in the current implementation of VST automation recording/playback, primarily focussed on reacting to parameter changes from the VST editor window, and also playing back already-recorded automation values to the VST. - -## What is good - -MidiRouter::IsParameterSuppressed preventing live per-param updates being sent back to VST until expiry has elapsed. - -## Problems: - -### Incorrect VST2 implementation -The host MUST call setParameterAutomated (on the VST instance) in response to changes received from the plugin calling audioMasterAutomate callback to host. This is expected host behaviour, and is not happening. - -### Calling setParameter incorrectly -We must not call setParameter too frequently in the audio callback - typically just once per block is sufficient (with the latest extrapolated value of each parameter in that block). So if linearly interpolating between two cointrol points, and we start at P1+delta and at end of block we would be at P1+delta+block then send the value at P1+delta+block. - -### AutomationEpsilon -The current epsilon approach (to not permit parameter changes too close to each other) may result in inaccuracies. Either remove epsilon or make it so small (just a few milliseconds) that it will not degrade timing accuracy. - -### Recordsing and overwriting control points -When a parameter value is received by the host via audioMasterAutomate call, we must immediately set automation record mode on (if not already), set a control point at current position, and also another control point 800ms in the future (wiping everything in between). This will also ensure to wipe previous 'end of 800ms' control points written previously by this same logic which are no longer required - for example if we get param values at 0ms, 700ms, 1400ms then we should end up with control points at 0ms, 700ms, 1400ms and (1400+800)=3200ms. The point written at 800ms (when the param received at 0ms) is wiped by the subsequent param value at 700ms, since it will wipe from 700ms to (700+800=1500ms) and leave a point at both 700ms and 1500ms. Then the param at 1400ms will wipe the one at 1500ms, and leave a point at 1400ms and 3200ms. - -### Too low a limit on control points -The current limit on number of points is too small - ideally the number of control points should be unlimited (but could merge any within a few milliseconds of each other). The logic to drop control points is not desirable. - -### Incorrect writing of control points -We seem to be continuously writing control points during PumpMidi even with no change - this is not correct. No points should be written here - we do want to keep track of whether 800ms has elapsed (to exit automation recording) but that can be done elsewhere. - -### Incorrect header comment -Header comment in MidiRouter.h still says fresh drag wipes lane points, but implementation in MidiRouter.cpp explicitly says "Do not clear the lane" and does not call clear. Ref: JammaLib/src/midi/MidiRouter.h#L193 - -### EditorTouchState -Comments indicate that VST params are written for every call to PumpMidi even if no change (confirmed in MidiRouter::ConsumeEditorAutomation implementation). That is incorrect - we should not write points if no change. By wiping all stored control points into the future 800ms after every actual change in param coming from CC or VST editor, and writing a control point value at both the start and end of this period, we avoid the need to continuously repeat the same parm value in the automation lane. Subsequent pumps do not affect this - we only write points/wipe points in response to changes, not regularly. - -### RefreshAutomationSuppression calls -We must call RefreshAutomationSuppression after every change in VST parameter received from the editor (and ideally from external CC, although that is out of scope for now). However, RefreshAutomationSuppression(...) is currently called in two places inside MidiRouter::_ConsumeEditorAutomation(...): - -1. On a new VST editor touch (newTouch == true) - - It refreshes the suppression entry for that (plugin, paramIdx). - - This happens when vst::_lastTouchedParam.Sequence changes. - - On every pump for every active editor touch state - -2. In the for (auto& state : _editorTouchStates) loop. - - As long as the touch state is active and not expired, suppression is refreshed again each cycle. - - So playback stays suppressed continuously near the live editor write-head. - -This is wrong. Yes it should be called on first touch, but also EVERY touch. Also it should not be called inside PumpMidi, there is no reason to keep refreshing that there, the expirySamp should represent the true sample pos to deactivate, which does not change based on PumpMidi calls - only in response to an actual received change in VST parameter. \ No newline at end of file diff --git a/doc/vst-editor-automation-handoff.md b/doc/vst-editor-automation-handoff.md deleted file mode 100644 index 0ec18b53..00000000 --- a/doc/vst-editor-automation-handoff.md +++ /dev/null @@ -1,95 +0,0 @@ -# VST Editor Automation Overwrite Notes - -## Status - -Resolved. The editor-driven automation overwrite is now implemented as a -bounded sample-domain overwrite window plus phase-anchored frac math. This note -documents the current design and replaces the earlier findings that described -the reverted experimental implementation. - -For the broader agent orientation see [automation-agent-brief.md](automation-agent-brief.md). - -## Behaviour - -When a VST editor parameter is dragged while automation record is held -(Ctrl+Shift+A), each real editor change: - -- Resolves the owning recording loop and a lane for `(plugin, param)`. -- Overwrites the half-open sample window `[touchSample, touchSample + 800ms)` - in that lane with the new value, removing existing points inside the window - and writing a held point at both the window start and end. -- Refreshes that parameter's playback suppression once, with an 800 ms - sample-domain deadline. - -Idle pump cycles do not write points or extend suppression; they only age out -touch state once no new touch has arrived for longer than the cool-down window. - -This satisfies the original request: recent editor motion overwrites existing -automation between the touch position and the end of the cool-down window, the -result persists after recording finishes, and wrapped windows split correctly -across the loop boundary. - -## Relevant code paths - -### Editor-touch ingestion — `JammaLib/src/midi/MidiRouter.cpp` - -- `MidiRouter::_ConsumeEditorAutomation(...)` - - Reads `vst::_lastTouchedParam.Sequence`, `Plugin`, `ParameterIndex`, `Value`. - - Always advances the sequence cursor so pre-arm touches are not replayed when - record mode is later armed. - - Only folds editor drags into automation while `_automationRecordHeld` is true. - - Part A (fresh touch): resolves loop + lane, computes the loop-relative sample - from `LoopPhaseAnchor()`, calls `MidiLoop::OverwriteAutomationWindow(...)`, - and calls `RefreshAutomationSuppression(...)` once. - - Part B (idle): expires stale `EditorTouchState` slots only. -- `MidiRouter::_ResetEditorTouchStates()` clears all touch state on arm. - -### Lane storage — `JammaLib/src/midi/MidiLoop.cpp` - -- `MidiLoop::OverwriteAutomationWindow(lane, startSample, durationSamples, value)` - - Wraps the window against `LoopLengthSamps()`, compacts surviving points in - place (two-pointer, no temporary buffer / no allocation), then writes the - held value at both window ends. -- `MidiLoop::SetAutomationValueAtFrac(...)` - - Keeps points sorted by frac; merges points within a fixed 10 ms window. - - Capacity is `AutomationLane::MaxPoints = 8192`, which is intentionally above - the practical merge ceiling. The merge window is the real density limit for - distinct points. -- `MidiLoop::EndRecord(loopLengthSamps, startGlobalSample)` - - Stores `LoopPhaseAnchor()` so dispatch and CC-record fracs stay in phase with - the visual play index. - -### Playback dispatch — `JammaLib/src/engine/Station.cpp` - -- `Station::RebuildAutomationDispatch()` bakes a flat list of active - `(plugin, param, loop, lane, loopPhaseAnchor, loopLengthSamps)` routes. -- `Station::_RunAutomationDispatch(blockStartSample, numSamps)` samples the lane - at the latest sample in the block (`blockStartSample + numSamps - 1`), skips - suppressed parameters, and calls `SetParameter` at most once per route per - block (delta-gated by `AutomationEpsilon`). - -### VST2 host callback — `JammaLib/src/vst/Vst2Plugin.cpp` - -- `audioMasterAutomate` publishes the touched `(plugin, param, value, sequence)` - tuple only. It deliberately does not echo the value back through - `setParameter`: per VST2 semantics the plugin already updated its own - parameter state (via `setParameterAutomated`) before notifying the host. - -## Tests - -- `test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp` - (`MidiAutomationPhaseAnchor.*`) — phase-anchor storage, frac math, point - insert/merge, and bounded/wrapped overwrite windows. -- `test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp` - (`StationAutomation.*`) — block-end sampling, suppression gating, and the - fresh-touch-only hold-window write. -- `test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp` - (`Vst2PluginHostCallback.AudioMasterAutomatePublishesTouchWithoutEchoingParameter`) - — confirms the host does not echo `setParameter`. - -## Known gaps - -- VST3 parameter playback is still a host-side no-op. -- Lane storage is fixed-capacity (matched to the merge epsilon, not dynamic). -- `AutomationEpsilon` (value-delta gate, `1 / 65536`) is intentionally retained; - it sits below 16-bit parameter resolution and does not distort timing. From 345287349d37219008e670bdb0216b9181d4594f Mon Sep 17 00:00:00 2001 From: malisimat Date: Sat, 20 Jun 2026 23:16:53 +0100 Subject: [PATCH 16/27] Remove old copilot instructions file --- .github/copilot-instructions.md | 201 -------------------------------- 1 file changed, 201 deletions(-) delete mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index c4eef3ab..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,201 +0,0 @@ -# Jamma Copilot Instructions - -## Project - -Jamma is a Windows multichannel loop-sampling app for recording and live performance. - -- App shell: `Jamma` -- Core engine: `JammaLib` -- Native tests: `test/JammaLib_Tests` - -Treat this as a real-time audio codebase: prefer predictable, low-latency-safe behavior over clever abstractions. - -## Architecture - -- Keep engine, loop, and audio behavior in `JammaLib`; keep `Jamma` as app entry and wiring. -- Core hierarchy: `Station -> LoopTake -> Loop`. -- Audio flow: - - ADC capture -> `ChannelMixer` -> latency-compensated writes into `Station` / `LoopTake` / `Loop` - - DAC playback <- `Station` / `LoopTake` / `Loop` mix/read <- `ChannelMixer` -- Keep glue code thin and explicit. Avoid cross-subsystem coupling. - -## Build - -Environment: - -- Windows -- Visual Studio 2022 with C++ desktop workload -- Windows SDK 10.0 -- Toolset `v145`, standard `stdcpplatest`, platform `x64` - -Fresh setup: - -Assume vcpkg is cloned externally, that VCPKG environment variable is set to vcpkg repo root, and that vcpkg is bootstrapped. Then install dependencies: - -```powershell -vcpkg install -``` - -Rules: - -1. Use incremental `Build` by default. Avoid `Clean` and `Rebuild` unless necessary. -2. Build only affected projects: - - `Jamma/src` changes -> `Jamma/Jamma.vcxproj` - - `JammaLib/src` or `JammaLib/include` changes -> `JammaLib/JammaLib.vcxproj`, then dependents as needed - - `test/JammaLib_Tests/src` changes -> `test/JammaLib_Tests/JammaLib_Tests.vcxproj` -3. Use solution builds only when project targeting is unclear. -4. If you hit `C1041` PDB contention, apply `/FS` and a project-specific `ProgramDataBaseFileName` in the affected project. - -MSBuild: - -```powershell -$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" -``` - -Preferred targeted builds: - -```powershell -$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" - -# Resolve repo root by walking upward until Jamma.sln is found. -$repoRoot = (Get-Location).Path -while (-not (Test-Path (Join-Path $repoRoot "Jamma.sln"))) { - $parent = Split-Path $repoRoot -Parent - if ($parent -eq $repoRoot) { - throw "Could not find Jamma.sln. Start in this repository or set `$repoRoot explicitly." - } - $repoRoot = $parent -} - -# Always pass absolute paths. For direct .vcxproj builds, pass SolutionDir explicitly. -$sln = Join-Path $repoRoot "Jamma.sln" -$jammaLibProj = Join-Path $repoRoot "JammaLib\JammaLib.vcxproj" -$jammaProj = Join-Path $repoRoot "Jamma\Jamma.vcxproj" -$testsProj = Join-Path $repoRoot "test\JammaLib_Tests\JammaLib_Tests.vcxproj" -$solutionDirArg = "/p:SolutionDir=$($repoRoot.TrimEnd('\\'))\" - -& $msbuild $jammaLibProj /m /t:Build /p:Configuration=Debug /p:Platform=x64 $solutionDirArg -& $msbuild $jammaProj /m /t:Build /p:Configuration=Debug /p:Platform=x64 $solutionDirArg -& $msbuild $testsProj /m /t:Build /p:Configuration=Debug /p:Platform=x64 $solutionDirArg - -# Optional: solution build if project targeting is unclear. -# & $msbuild $sln /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:VcpkgEnableManifest=true -``` - -**Important:** For direct `.vcxproj` builds, derive repo root from `Jamma.sln` and pass `/p:SolutionDir=\` with exactly one trailing backslash. Do not use `$(pwd)` or a doubled trailing slash; mismatched `SolutionDir` text can invalidate `.tlog` state and trigger full test recompiles. - -### Running tests: - -Use solution builds sparingly: - -```powershell -$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" - -$repoRoot = (Get-Location).Path -while (-not (Test-Path (Join-Path $repoRoot "Jamma.sln"))) { - $parent = Split-Path $repoRoot -Parent - if ($parent -eq $repoRoot) { - throw "Could not find Jamma.sln. Start in this repository or set `$repoRoot explicitly." - } - $repoRoot = $parent -} - -$testsProj = Join-Path $repoRoot "test\JammaLib_Tests\JammaLib_Tests.vcxproj" -$testsExe = Join-Path $repoRoot "test\JammaLib_Tests\bin\x64\Debug\JammaLib_Tests.exe" -$solutionDirArg = "/p:SolutionDir=$($repoRoot.TrimEnd('\\'))\" - -& $msbuild $testsProj /m /t:Build /p:Configuration=Debug /p:Platform=x64 $solutionDirArg -& $testsExe -``` - -## Tests - -- For behavior changes in `JammaLib`, add or update tests when practical. -- Build and run tests with: - -```powershell -& $msbuild test\JammaLib_Tests\JammaLib_Tests.vcxproj /m /t:Build /p:Configuration=Debug /p:Platform=x64 -& .\test\JammaLib_Tests\bin\x64\Debug\JammaLib_Tests.exe -``` - -- Run a specific test with: - -```powershell -$repoRoot = (Get-Location).Path -while (-not (Test-Path (Join-Path $repoRoot "Jamma.sln"))) { - $parent = Split-Path $repoRoot -Parent - if ($parent -eq $repoRoot) { - throw "Could not find Jamma.sln. Start in this repository or set `$repoRoot explicitly." - } - $repoRoot = $parent -} - -$testsExe = Join-Path $repoRoot "test\JammaLib_Tests\bin\x64\Debug\JammaLib_Tests.exe" -& $testsExe --gtest_filter="SuiteName.TestName" -``` - -Troubleshooting: - -- If Google Test headers or libraries are missing, verify `vcpkg integrate install`, `vcpkg install`, and that `vcpkg_installed/` contains `gtest`. -- If the test exe exits with code `1` and no output, stale Release gtest DLLs may be in the Debug output folder. Copy the Debug DLLs back in: - -```powershell -$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" - -$repoRoot = (Get-Location).Path -while (-not (Test-Path (Join-Path $repoRoot "Jamma.sln"))) { - $parent = Split-Path $repoRoot -Parent - if ($parent -eq $repoRoot) { - throw "Could not find Jamma.sln. Start in this repository or set `$repoRoot explicitly." - } - $repoRoot = $parent -} - -$sln = Join-Path $repoRoot "Jamma.sln" -& $msbuild $sln /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:VcpkgEnableManifest=true -& $msbuild $sln /m /t:Build /p:Configuration=Release /p:Platform=x64 /p:VcpkgEnableManifest=true -``` - -## Coding Guidance - -Prefer modern C++ and functional style where practical. - -Real-time audio rules for callback and hot paths: - -- No heap allocation. -- No exceptions. -- No blocking I/O or unpredictable locks. -- No heavy logging or system calls. -- Prefer the fastest safe read/write path. -- Pre-allocate and reuse buffers and resources. - -Hotpath review rule after edits in audio-thread code: - -- Manually inspect these callback-owned functions before finishing a change: `Scene::OnTick`, `Scene::AudioCallback`, `Scene::_OnAudio`, `Loop::WriteBlock`, `LoopTake::Zero`, `LoopTake::WriteBlock`, `LoopTake::EndMultiPlay`, `LoopTake::EndMultiWrite`, `LoopTake::_InputChannel`, `Station::Zero`, `Station::WriteBlock`, `Station::EndMultiPlay`, `Station::OnBlockWriteChannel`, `Station::EndMultiWrite`, `Station::OnBounce`, `Station::_InputChannel`, `Trigger::OnTick`, `NinjamConnection::ProcessAudioBlock`, and `NinjamConnection::ConsumeStereoPair`. -- Reject any addition of blocking or lock-based primitives inside those bodies, including `std::mutex`, `std::scoped_lock`, `std::lock_guard`, `std::unique_lock`, `std::condition_variable`, `EnterCriticalSection`, `WaitForSingleObject`, `SleepConditionVariableCS`, and `SleepConditionVariableSRW`. - -General guidance: - -- Prefer value semantics, pure transformations, and explicit inputs/outputs. -- Isolate side effects such as I/O, audio device access, rendering, filesystem work, and threading. -- Use `std::vector`, `std::array`, `std::string`, RAII, and smart pointers when performance allows. -- Prefer `std::optional`, `std::variant`, and strong enums over sentinel values. -- Use algorithms and ranges only when they improve clarity without hurting hot paths. -- Use `const` aggressively; pass large objects by `const&`. -- Use `constexpr` and `noexcept` when correct. - -Avoid: - -- Raw owning pointers or manual `new` and `delete` unless measurably better in hot paths. -- Hidden global mutable state. -- Monolithic functions mixing state changes, I/O, and control flow. -- Exception-driven control flow in real-time paths. - -## Change Expectations - -1. Respect subsystem ownership and existing naming. -2. Keep diffs focused and minimal. -3. Add or update tests for behavior changes when feasible. -4. Prefer readability and maintainability over template or macro cleverness. -5. Briefly document non-obvious invariants. -6. Keep hot-path optimizations explicit and maintainable. From 259fc82cd1a78485f525384c905807fb4887eb54 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 07:40:53 +0100 Subject: [PATCH 17/27] Add VST3 support for automation recording and playback --- JammaLib/src/vst/IVstPlugin.h | 7 + JammaLib/src/vst/Vst.cpp | 10 ++ JammaLib/src/vst/Vst2Plugin.cpp | 5 +- JammaLib/src/vst/Vst3Plugin.cpp | 308 +++++++++++++++++++++++++++++++- JammaLib/src/vst/Vst3Plugin.h | 10 +- 5 files changed, 325 insertions(+), 15 deletions(-) diff --git a/JammaLib/src/vst/IVstPlugin.h b/JammaLib/src/vst/IVstPlugin.h index d2ee2bce..890e14d2 100644 --- a/JammaLib/src/vst/IVstPlugin.h +++ b/JammaLib/src/vst/IVstPlugin.h @@ -144,6 +144,13 @@ namespace vst }; extern LastTouchedParameter _lastTouchedParam; + // Publish a host automation touch event into the shared registry. + // Writers store the triple first, then release-bump Sequence so readers can + // acquire-load Sequence and observe coherent Plugin/ParameterIndex/Value. + void PublishLastTouchedParameter(IVstPlugin* plugin, + unsigned int parameterIndex, + float value) noexcept; + // Factory: creates the correct plugin type based on file extension. // Extension ".dll" -> Vst2Plugin (VST2) // Any other extension (e.g. ".vst3") -> Vst3Plugin (VST3) diff --git a/JammaLib/src/vst/Vst.cpp b/JammaLib/src/vst/Vst.cpp index 9d505f21..aaf6a6ea 100644 --- a/JammaLib/src/vst/Vst.cpp +++ b/JammaLib/src/vst/Vst.cpp @@ -6,5 +6,15 @@ namespace vst { // Definition of the global last-touched parameter registry declared in IVstPlugin.h. LastTouchedParameter _lastTouchedParam; + + void PublishLastTouchedParameter(IVstPlugin* plugin, + unsigned int parameterIndex, + float value) noexcept + { + _lastTouchedParam.Plugin.store(plugin, std::memory_order_relaxed); + _lastTouchedParam.ParameterIndex.store(parameterIndex, std::memory_order_relaxed); + _lastTouchedParam.Value.store(value, std::memory_order_relaxed); + _lastTouchedParam.Sequence.fetch_add(1u, std::memory_order_release); + } } diff --git a/JammaLib/src/vst/Vst2Plugin.cpp b/JammaLib/src/vst/Vst2Plugin.cpp index a3929e27..84979b70 100644 --- a/JammaLib/src/vst/Vst2Plugin.cpp +++ b/JammaLib/src/vst/Vst2Plugin.cpp @@ -571,10 +571,7 @@ VstIntPtr __cdecl Vst2Plugin::HostCallback(AEffect* effect, // changed its own parameter state before calling audioMasterAutomate. if (self) { - _lastTouchedParam.Plugin.store(self, std::memory_order_relaxed); - _lastTouchedParam.ParameterIndex.store(static_cast(index), std::memory_order_relaxed); - _lastTouchedParam.Value.store(opt, std::memory_order_relaxed); - _lastTouchedParam.Sequence.fetch_add(1u, std::memory_order_release); + PublishLastTouchedParameter(self, static_cast(index), opt); } return 0; case audioMasterIdle: diff --git a/JammaLib/src/vst/Vst3Plugin.cpp b/JammaLib/src/vst/Vst3Plugin.cpp index 456d9089..ad44976e 100644 --- a/JammaLib/src/vst/Vst3Plugin.cpp +++ b/JammaLib/src/vst/Vst3Plugin.cpp @@ -9,16 +9,19 @@ #include "Vst2Plugin.h" #include #include +#include #include #include #include -#include #include +#include +#include #ifdef JAMMA_VST3_ENABLED #include "vst3sdk/pluginterfaces/base/ipluginbase.h" #include "vst3sdk/pluginterfaces/vst/ivstmessage.h" #include "vst3sdk/pluginterfaces/vst/ivstaudioprocessor.h" +#include "vst3sdk/pluginterfaces/vst/ivstparameterchanges.h" #include "vst3sdk/pluginterfaces/vst/ivsteditcontroller.h" #include "vst3sdk/pluginterfaces/vst/ivstevents.h" #include "vst3sdk/pluginterfaces/vst/ivstmidicontrollers.h" @@ -103,13 +106,19 @@ IMPLEMENT_FUNKNOWN_METHODS(HostPlugFrame, IPlugFrame, IPlugFrame::iid) class HostComponentHandler final : public IComponentHandler { public: - HostComponentHandler() + HostComponentHandler() : + _owner(nullptr) { FUNKNOWN_CTOR } ~HostComponentHandler() noexcept { FUNKNOWN_DTOR } + void SetOwner(Vst3Plugin* owner) noexcept + { + _owner = owner; + } + tresult PLUGIN_API beginEdit(ParamID id) override { (void)id; @@ -118,8 +127,8 @@ class HostComponentHandler final : public IComponentHandler tresult PLUGIN_API performEdit(ParamID id, ParamValue valueNormalized) override { - (void)id; - (void)valueNormalized; + if (_owner) + _owner->OnControllerEdit(static_cast(id), static_cast(valueNormalized)); return kResultOk; } @@ -135,6 +144,10 @@ class HostComponentHandler final : public IComponentHandler return kResultOk; } + private: + Vst3Plugin* _owner; + + public: DECLARE_FUNKNOWN_METHODS }; @@ -277,6 +290,150 @@ class FixedEventList final : public IEventList IMPLEMENT_FUNKNOWN_METHODS(FixedEventList, IEventList, IEventList::iid) +class FixedParamValueQueue final : public IParamValueQueue +{ +public: + static constexpr int32 MaxPoints = 32; + + FixedParamValueQueue() : + _paramId(0), + _count(0), + _offsets{}, + _values{} + { + FUNKNOWN_CTOR + } + + ~FixedParamValueQueue() noexcept { FUNKNOWN_DTOR } + + void Begin(ParamID paramId) noexcept + { + _paramId = paramId; + _count = 0; + } + + void Clear() noexcept + { + _count = 0; + } + + ParamID PLUGIN_API getParameterId() override + { + return _paramId; + } + + int32 PLUGIN_API getPointCount() override + { + return _count; + } + + tresult PLUGIN_API getPoint(int32 index, int32& sampleOffset, ParamValue& value) override + { + if (index < 0 || index >= _count) + return kInvalidArgument; + + sampleOffset = _offsets[static_cast(index)]; + value = _values[static_cast(index)]; + return kResultOk; + } + + tresult PLUGIN_API addPoint(int32 sampleOffset, ParamValue value, int32& index) override + { + if (_count >= MaxPoints) + { + index = _count - 1; + if (index >= 0) + { + _offsets[static_cast(index)] = sampleOffset; + _values[static_cast(index)] = value; + return kResultOk; + } + return kOutOfMemory; + } + + index = _count; + _offsets[static_cast(_count)] = sampleOffset; + _values[static_cast(_count)] = value; + ++_count; + return kResultOk; + } + + DECLARE_FUNKNOWN_METHODS + +private: + ParamID _paramId; + int32 _count; + std::array _offsets; + std::array _values; +}; + +IMPLEMENT_FUNKNOWN_METHODS(FixedParamValueQueue, IParamValueQueue, IParamValueQueue::iid) + +class FixedParameterChanges final : public IParameterChanges +{ +public: + static constexpr int32 MaxQueues = 256; + + FixedParameterChanges() : + _count(0), + _queues{} + { + FUNKNOWN_CTOR + } + + ~FixedParameterChanges() noexcept { FUNKNOWN_DTOR } + + void BeginBlock() noexcept + { + _count = 0; + } + + int32 PLUGIN_API getParameterCount() override + { + return _count; + } + + IParamValueQueue* PLUGIN_API getParameterData(int32 index) override + { + if (index < 0 || index >= _count) + return nullptr; + return &_queues[static_cast(index)]; + } + + IParamValueQueue* PLUGIN_API addParameterData(const ParamID& id, int32& index) override + { + for (int32 i = 0; i < _count; ++i) + { + auto& queue = _queues[static_cast(i)]; + if (queue.getParameterId() == id) + { + index = i; + return &queue; + } + } + + if (_count >= MaxQueues) + { + index = _count - 1; + return nullptr; + } + + auto& queue = _queues[static_cast(_count)]; + queue.Begin(id); + index = _count; + ++_count; + return &queue; + } + + DECLARE_FUNKNOWN_METHODS + +private: + int32 _count; + std::array _queues; +}; + +IMPLEMENT_FUNKNOWN_METHODS(FixedParameterChanges, IParameterChanges, IParameterChanges::iid) + class Vst3Plugin::Impl { public: @@ -306,6 +463,61 @@ class Vst3Plugin::Impl std::vector inputScratchStorage; std::vector outputScratchStorage; std::unique_ptr inputEvents; + std::unique_ptr inputParameterChanges; + Steinberg::Vst::ProcessContext processContext; + vst::HostTimeState hostTime; + std::vector hostIndexToParamId; + std::unordered_map paramIdToHostIndex; + + void ClearParameterState() noexcept + { + hostIndexToParamId.clear(); + paramIdToHostIndex.clear(); + if (inputParameterChanges) + inputParameterChanges->BeginBlock(); + } + + void BuildParameterMaps() noexcept + { + hostIndexToParamId.clear(); + paramIdToHostIndex.clear(); + if (!controller) + return; + + const auto count = controller->getParameterCount(); + if (count <= 0) + return; + + hostIndexToParamId.resize(static_cast(count), static_cast(0)); + for (int32 i = 0; i < count; ++i) + { + ParameterInfo info{}; + if (controller->getParameterInfo(i, info) != kResultOk) + continue; + + hostIndexToParamId[static_cast(i)] = info.id; + paramIdToHostIndex[static_cast(info.id)] = static_cast(i); + } + } + + bool TryGetHostIndexForParamId(ParamID paramId, unsigned int& hostIndex) const noexcept + { + auto it = paramIdToHostIndex.find(static_cast(paramId)); + if (it == paramIdToHostIndex.end()) + return false; + + hostIndex = it->second; + return true; + } + + bool TryGetParamIdForHostIndex(unsigned int hostIndex, ParamID& paramId) const noexcept + { + if (hostIndex >= hostIndexToParamId.size()) + return false; + + paramId = hostIndexToParamId[hostIndex]; + return true; + } Impl() : factory(nullptr), @@ -328,7 +540,12 @@ class Vst3Plugin::Impl outputChannelPtrs(), inputScratchStorage(), outputScratchStorage(), - inputEvents(std::make_unique()) + inputEvents(std::make_unique()), + inputParameterChanges(std::make_unique()), + processContext(), + hostTime(), + hostIndexToParamId(), + paramIdToHostIndex() { } }; @@ -350,6 +567,10 @@ Vst3Plugin::Vst3Plugin() : #endif ) { +#ifdef JAMMA_VST3_ENABLED + if (_impl && _impl->componentHandler) + _impl->componentHandler->SetOwner(this); +#endif } Vst3Plugin::~Vst3Plugin() @@ -792,9 +1013,10 @@ bool Vst3Plugin::Load(const std::wstring& path, _impl->processData.outputs = (outputBusCount > 0) ? &_impl->outputBus : nullptr; _impl->processData.inputEvents = _impl->inputEvents.get(); _impl->processData.outputEvents = nullptr; - _impl->processData.inputParameterChanges = nullptr; + _impl->processData.inputParameterChanges = _impl->inputParameterChanges.get(); _impl->processData.outputParameterChanges = nullptr; - _impl->processData.processContext = nullptr; + _impl->processData.processContext = &_impl->processContext; + _impl->inputParameterChanges->BeginBlock(); // 10. Optionally retrieve IEditController (may be the same object or separate) IEditController* rawController = nullptr; @@ -827,6 +1049,7 @@ bool Vst3Plugin::Load(const std::wstring& path, std::cout << "[Vst3Plugin] No controller available (editor may not open)" << std::endl; else { + _impl->BuildParameterMaps(); _impl->controller->setComponentHandler(_impl->componentHandler.get()); } @@ -918,6 +1141,8 @@ void Vst3Plugin::ResetLoadedObjects(bool terminateComponent) if (_impl->controller) _impl->controller->setComponentHandler(nullptr); + _impl->ClearParameterState(); + _impl->processor = nullptr; _impl->controller = nullptr; _impl->componentConnection = nullptr; @@ -967,6 +1192,7 @@ void Vst3Plugin::ProcessBlock(float* monoBuf, int32_t numSamples) noexcept _impl->processData.numSamples = numSamples; _impl->processor->process(_impl->processData); + _impl->inputParameterChanges->BeginBlock(); FoldOutputToMono(_impl->outputChannelPtrs.data(), _impl->outputChannels, numSamples, monoBuf); #else @@ -1008,6 +1234,7 @@ void Vst3Plugin::ProcessBlockStereo(float* leftBuf, float* rightBuf, int32_t num _impl->processData.numSamples = numSamples; _impl->processor->process(_impl->processData); + _impl->inputParameterChanges->BeginBlock(); float* outputChannels[] = { leftBuf, rightBuf }; CopyOutputToMulti(_impl->outputChannelPtrs.data(), _impl->outputChannels, numSamples, outputChannels, 2); @@ -1043,6 +1270,7 @@ void Vst3Plugin::ProcessBlockMulti(float* const* channelBufs, int32_t numChannel CopyMultiToInputBuffers(channelBufs, numChannels, numSamples, _impl->inputChannelPtrs.data(), _impl->inputChannels); _impl->processData.numSamples = numSamples; _impl->processor->process(_impl->processData); + _impl->inputParameterChanges->BeginBlock(); CopyOutputToMulti(_impl->outputChannelPtrs.data(), _impl->outputChannels, numSamples, channelBufs, numChannels); #else (void)channelBufs; (void)numChannels; (void)numSamples; @@ -1051,14 +1279,78 @@ void Vst3Plugin::ProcessBlockMulti(float* const* channelBufs, int32_t numChannel void Vst3Plugin::SetParameter(unsigned int index, float value) noexcept { - // VST3 parameter automation is not yet routed through this host. +#ifdef JAMMA_VST3_ENABLED + if (!_impl || !_isLoaded || !_impl->inputParameterChanges) + return; + + ParamID paramId = 0; + if (!_impl->TryGetParamIdForHostIndex(index, paramId)) + return; + + int32 queueIndex = -1; + auto* queue = _impl->inputParameterChanges->addParameterData(paramId, queueIndex); + if (!queue) + return; + + const auto normalized = std::clamp(static_cast(value), 0.0, 1.0); + int32 pointIndex = -1; + queue->addPoint(0, normalized, pointIndex); +#else (void)index; (void)value; +#endif } float Vst3Plugin::GetParameter(unsigned int index) const noexcept { +#ifdef JAMMA_VST3_ENABLED + if (!_impl || !_impl->controller) + return 0.0f; + + ParamID paramId = 0; + if (!_impl->TryGetParamIdForHostIndex(index, paramId)) + return 0.0f; + + const auto normalized = _impl->controller->getParamNormalized(paramId); + return std::clamp(static_cast(normalized), 0.0f, 1.0f); +#else (void)index; return 0.0f; +#endif +} + +void Vst3Plugin::UpdateHostTime(const HostTimeState& state) noexcept +{ +#ifdef JAMMA_VST3_ENABLED + if (!_impl) + return; + + _impl->hostTime = state; + _impl->processContext.projectTimeSamples = state.samplePos; + _impl->processContext.tempo = state.tempo; + _impl->processContext.timeSigNumerator = state.bpi; + _impl->processContext.timeSigDenominator = 4; + _impl->processContext.state = state.isPlaying ? 1u : 0u; + _impl->processContext.sampleRate = state.sampleRate; +#else + (void)state; +#endif +} + +void Vst3Plugin::OnControllerEdit(std::uint32_t paramId, float normalizedValue) noexcept +{ +#ifdef JAMMA_VST3_ENABLED + if (!_impl) + return; + + unsigned int hostIndex = 0u; + if (!_impl->TryGetHostIndexForParamId(static_cast(paramId), hostIndex)) + return; + + PublishLastTouchedParameter(this, hostIndex, std::clamp(normalizedValue, 0.0f, 1.0f)); +#else + (void)paramId; + (void)normalizedValue; +#endif } void Vst3Plugin::BeginMidiBlock(std::uint32_t blockStartSample, diff --git a/JammaLib/src/vst/Vst3Plugin.h b/JammaLib/src/vst/Vst3Plugin.h index b89b9334..aaa94db4 100644 --- a/JammaLib/src/vst/Vst3Plugin.h +++ b/JammaLib/src/vst/Vst3Plugin.h @@ -79,9 +79,9 @@ namespace vst // channelBufs must contain numChannels writable channel buffers. void ProcessBlockMulti(float* const* channelBufs, int32_t numChannels, int32_t numSamples) noexcept override; - // Set / get a hosted parameter by index. VST3 parameter automation is not - // wired through this host yet, so these are no-op stubs that satisfy the - // IVstPlugin interface (GetParameter returns 0). + // Set / get a hosted parameter by host index (mapped to VST3 ParamID). + // SetParameter queues a normalized automation point for the next process + // block; GetParameter reads the current normalized value from controller. void SetParameter(unsigned int index, float value) noexcept override; float GetParameter(unsigned int index) const noexcept override; @@ -89,6 +89,10 @@ namespace vst std::uint32_t numSamples) noexcept override; void SendMidiEvent(const midi::MidiEvent& event, bool isRealtime) noexcept override; + void UpdateHostTime(const HostTimeState& state) noexcept override; + + // Host callback entry point used by the VST3 component handler. + void OnControllerEdit(std::uint32_t paramId, float normalizedValue) noexcept; // Open the plugin's GUI editor as a child of parentHwnd. // Must be called from the main/UI thread only. From 85ad41d9882e8d94f57dfba54d2054960b5f6ac9 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 10:13:05 +0100 Subject: [PATCH 18/27] Align max automation control points with shader uniform, and evict points ahead of write position --- Jamma/resources/shaders/automation.vert | 5 +- JammaLib/src/midi/MidiLoop.cpp | 188 ++++++++++++------ JammaLib/src/midi/MidiLoop.h | 31 ++- .../MidiAutomationLaneResolution_Tests.cpp | 57 ++++++ 4 files changed, 218 insertions(+), 63 deletions(-) diff --git a/Jamma/resources/shaders/automation.vert b/Jamma/resources/shaders/automation.vert index 4c9f5150..4eb9f046 100644 --- a/Jamma/resources/shaders/automation.vert +++ b/Jamma/resources/shaders/automation.vert @@ -11,7 +11,8 @@ out float vHeight; // sampled automation value 0..1 uniform mat4 MVP; // Sparse control points (frac, value), piecewise-linear in loop space. -uniform vec2 AutoPoints[256]; +const int MaxAutoPoints = 512; +uniform vec2 AutoPoints[MaxAutoPoints]; uniform int AutoPointCount; uniform float LaneRadius; @@ -37,7 +38,7 @@ float SampleAutomation(float t) if (t >= AutoPoints[last].x) return AutoPoints[last].y; - for (int i = 0; i < 255; ++i) + for (int i = 0; i < MaxAutoPoints - 1; ++i) { if (i + 1 > last) break; diff --git a/JammaLib/src/midi/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index f4fbe3ec..2f2ba6b4 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -10,80 +10,152 @@ using namespace midi; -namespace +float MidiLoop::MsToLoopFrac(float ms, float sampleRate, std::uint32_t loopLengthSamps) noexcept { - static constexpr std::uint32_t MidiModelUpdateIntervalSamps = constants::DefaultSampleRate / 30u; - static constexpr float AutomationMergeWindowMs = 10.0f; + if (0u == loopLengthSamps || sampleRate <= 0.0f) + return 0.0f; - float ClampAutomationFrac(double frac) noexcept - { - auto fracF = static_cast(frac); - if (fracF < 0.0f) - return 0.0f; - if (fracF > 1.0f) - return 1.0f; - return fracF; - } + const auto frac = (ms * sampleRate / 1000.0f) / static_cast(loopLengthSamps); + if (frac <= 0.0f) return 0.0f; + if (frac >= 1.0f) return 1.0f; + return frac; +} - float AutomationMergeWindowFrac(float sampleRate, std::uint32_t loopLengthSamps) noexcept - { - if (0u == loopLengthSamps || sampleRate <= 0.0f) - return 0.0f; +bool MidiLoop::FracIsAheadWithin(float candidateFrac, + float startFrac, + float windowFrac) noexcept +{ + if (windowFrac <= 0.0f) return false; + if (windowFrac >= 1.0f) return true; - const auto mergeWindowSamps = sampleRate * AutomationMergeWindowMs / 1000.0f; - return mergeWindowSamps / static_cast(loopLengthSamps); - } + float ahead = candidateFrac - startFrac; + if (ahead < 0.0f) ahead += 1.0f; + return ahead < windowFrac; +} - void InsertOrUpdateAutomationPoint(std::array, midi::AutomationLane::MaxPoints>& points, - std::size_t& count, - float sampleRate, - std::uint32_t loopLengthSamps, - float frac, - float value) noexcept - { - const auto automationFracEpsilon = AutomationMergeWindowFrac(sampleRate, loopLengthSamps); +float MidiLoop::ClampAutomationFrac(double frac) noexcept +{ + auto fracF = static_cast(frac); + if (fracF < 0.0f) + return 0.0f; + if (fracF > 1.0f) + return 1.0f; + return fracF; +} - std::size_t insertAt = 0u; - while (insertAt < count && points[insertAt].first < frac) - ++insertAt; +void MidiLoop::InsertOrUpdateAutomationPoint(std::array, AutomationLane::MaxPoints>& points, + std::size_t& count, + float sampleRate, + std::uint32_t loopLengthSamps, + float frac, + float value) noexcept +{ + const auto automationFracEpsilon = MsToLoopFrac(AutomationMergeWindowMs, sampleRate, loopLengthSamps); - if (insertAt < count && (points[insertAt].first - frac) <= automationFracEpsilon) - { - points[insertAt].second = value; - } - else if (insertAt > 0u && (frac - points[insertAt - 1u].first) <= automationFracEpsilon) + // Find the first point at or ahead of frac (sorted array; insertAt == count means all are behind). + std::size_t insertAt = 0u; + while (insertAt < count && points[insertAt].first < frac) + ++insertAt; + + // Merge into the nearest existing point if it falls within the snap window. + // Prefer the ahead point over the behind point: it's what playback encounters next, + // and on a running write-head it's more likely to be in the overwrite zone. + if (insertAt < count && (points[insertAt].first - frac) <= automationFracEpsilon) + { + // Loop invariant guarantees points[insertAt].first >= frac, so the difference is non-negative. + points[insertAt].second = value; + } + else if (insertAt > 0u && (frac - points[insertAt - 1u].first) <= automationFracEpsilon) + { + points[insertAt - 1u].second = value; + } + else if (count < AutomationLane::MaxPoints) + { + // Shift right and insert in sorted order. + for (std::size_t i = count; i > insertAt; --i) + points[i] = points[i - 1u]; + points[insertAt] = std::make_pair(frac, value); + ++count; + } + else if (count > 0u) + { + // Array is full and no nearby point to merge into — must evict one entry. + // Prefer evicting ahead of the write-head (likely overwrite territory), + // but protect the immediate future hold region used by editor automation. + const auto protectWindowFrac = MsToLoopFrac(AutomationFutureProtectWindowMs, sampleRate, loopLengthSamps); + std::size_t evictAt = count; // sentinel: no candidate yet + + // Pass 1: scan forward from the insertion position for the first ahead-point + // that lies outside the protect window (i.e., far enough ahead to be safe to lose). + for (std::size_t i = insertAt; i < count; ++i) { - points[insertAt - 1u].second = value; + if (!FracIsAheadWithin(points[i].first, frac, protectWindowFrac)) + { + evictAt = i; + break; + } } - else if (count < midi::AutomationLane::MaxPoints) + + // Pass 2 (fallback): all ahead points are protected, so scan backward from the + // insertion position looking for a point that is NOT in the wrap-around future + // protect window (i.e., sufficiently far behind in the loop). + // Guard skipped when protectWindowFrac == 0: in that case Pass 1 always succeeds + // immediately since FracIsAheadWithin always returns false. + if (protectWindowFrac > 0.0f && evictAt == count) { - for (std::size_t i = count; i > insertAt; --i) - points[i] = points[i - 1u]; - points[insertAt] = std::make_pair(frac, value); - ++count; + for (std::size_t i = insertAt; i > 0u; --i) + { + const auto idx = i - 1u; + if (!FracIsAheadWithin(points[idx].first, frac, protectWindowFrac)) + { + evictAt = idx; + break; + } + } } - } - float SampleToAutomationFrac(std::uint32_t sample, - std::uint32_t loopLengthSamps) noexcept - { - if (0u == loopLengthSamps) - return 0.0f; - return static_cast(sample % loopLengthSamps) - / static_cast(loopLengthSamps); - } + // Last resort: everything is within the protect window (e.g. all points clustered + // around the write-head). Evict the nearest-ahead point, or index 0 if frac is + // past all existing points. + if (evictAt == count) + evictAt = (insertAt < count) ? insertAt : 0u; - bool FracWithinOverwriteWindow(float frac, - float startFrac, - float endFrac, - bool wraps) noexcept - { - if (!wraps) - return frac >= startFrac && frac < endFrac; + // Compact the array by removing the evicted entry. + for (std::size_t i = evictAt + 1u; i < count; ++i) + points[i - 1u] = points[i]; + --count; - return frac >= startFrac || frac < endFrac; + // Re-scan for the insertion position: evicting a point before the original + // insertAt shifts all subsequent indices, so we cannot reuse the old value. + insertAt = 0u; + while (insertAt < count && points[insertAt].first < frac) + ++insertAt; + + for (std::size_t i = count; i > insertAt; --i) + points[i] = points[i - 1u]; + points[insertAt] = std::make_pair(frac, value); + ++count; } +} + +float MidiLoop::SampleToAutomationFrac(std::uint32_t sample, + std::uint32_t loopLengthSamps) noexcept +{ + if (0u == loopLengthSamps) + return 0.0f; + return static_cast(sample % loopLengthSamps) + / static_cast(loopLengthSamps); +} + +bool MidiLoop::FracWithinOverwriteWindow(float frac, + float startFrac, + float endFrac, + bool wraps) noexcept +{ + if (!wraps) + return frac >= startFrac && frac < endFrac; + return frac >= startFrac || frac < endFrac; } MidiLoop::MidiLoop() noexcept diff --git a/JammaLib/src/midi/MidiLoop.h b/JammaLib/src/midi/MidiLoop.h index 9f0ce1cf..0f0e0378 100644 --- a/JammaLib/src/midi/MidiLoop.h +++ b/JammaLib/src/midi/MidiLoop.h @@ -11,6 +11,7 @@ #include #include "../graphics/MidiModel.h" +#include "../include/Constants.h" #include "MidiEvent.h" #include "MidiQuantisation.h" @@ -96,9 +97,9 @@ namespace midi // sparse control-point buffer recorded along the loop timeline. struct AutomationLane { - // Keep the storage comfortably above the merge threshold so the point cap is - // not the limiting factor for dense automation curves. - static constexpr std::size_t MaxPoints = 8192u; + // Keep the storage aligned with the renderer's uniform cap so recording and + // display stay bounded by the same predictable limit. + static constexpr std::size_t MaxPoints = 512u; AutomationMapping Mapping; std::array, MaxPoints> Points{}; // (frac, value) @@ -283,6 +284,30 @@ namespace midi void FlushHeldNotes(std::uint32_t atGlobalSample, IMidiSink& sink) noexcept; void PublishQuantisedEvents(); + // --- Automation point helpers --- + static constexpr std::uint32_t MidiModelUpdateIntervalSamps = constants::DefaultSampleRate / 30u; + static constexpr float AutomationMergeWindowMs = 10.0f; + static constexpr float AutomationFutureProtectWindowMs = 800.0f; + + // Convert a duration in milliseconds to a fractional position within the + // loop, clamped to [0, 1]. Returns 0 when loop length or sample rate is not resolved. + static float MsToLoopFrac(float ms, float sampleRate, std::uint32_t loopLengthSamps) noexcept; + + // True when candidateFrac falls inside the half-open circular window + // [startFrac, startFrac + windowFrac) (with loop wraparound). + static bool FracIsAheadWithin(float candidateFrac, float startFrac, float windowFrac) noexcept; + + static float ClampAutomationFrac(double frac) noexcept; + static void InsertOrUpdateAutomationPoint( + std::array, AutomationLane::MaxPoints>& points, + std::size_t& count, + float sampleRate, + std::uint32_t loopLengthSamps, + float frac, + float value) noexcept; + static float SampleToAutomationFrac(std::uint32_t sample, std::uint32_t loopLengthSamps) noexcept; + static bool FracWithinOverwriteWindow(float frac, float startFrac, float endFrac, bool wraps) noexcept; + std::array _events{}; std::atomic _quantisedEvents; std::vector> _retainedQuantisedEvents; diff --git a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp index eba06b11..42a53745 100644 --- a/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp +++ b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp @@ -275,6 +275,35 @@ TEST(MidiAutomationPhaseAnchor, DistinctFracsFillLaneThenReplaceInPlace) EXPECT_NEAR(0.6f, points[3].second, 1.0e-5f); } +TEST(MidiAutomationPhaseAnchor, OverflowEvictsOldestPoint) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x811u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 4u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 4u)); + + const std::size_t totalPoints = midi::AutomationLane::MaxPoints + 1u; + for (std::size_t i = 0u; i < totalPoints; ++i) + { + const float frac = static_cast(i) / static_cast(totalPoints); + loop.SetAutomationValueAtFrac(*lane, frac, frac); + } + + std::array, midi::AutomationLane::MaxPoints> points{}; + const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + ASSERT_EQ(midi::AutomationLane::MaxPoints, count); + EXPECT_NEAR(1.0f / static_cast(totalPoints), points[0].first, 1.0e-6f); + EXPECT_NEAR(1.0f / static_cast(totalPoints), points[0].second, 1.0e-6f); + EXPECT_NEAR(static_cast(totalPoints - 1u) / static_cast(totalPoints), + points[count - 1u].first, + 1.0e-6f); + EXPECT_NEAR(static_cast(totalPoints - 1u) / static_cast(totalPoints), + points[count - 1u].second, + 1.0e-6f); +} + TEST(MidiAutomationPhaseAnchor, OverwriteWindowReplacesTouchedFutureRange) { MidiLoop loop; @@ -330,3 +359,31 @@ TEST(MidiAutomationPhaseAnchor, OverwriteWindowWrapsAcrossLoopBoundary) EXPECT_NEAR(0.70f, points[1].first, 1.0e-6f); EXPECT_NEAR(0.6f, points[1].second, 1.0e-6f); } + +TEST(MidiAutomationPhaseAnchor, ShortLoopOverwriteRemainsSortedAndBounded) +{ + MidiLoop loop; + auto* plugin = FakePlugin(0x822u); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 5u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 5u)); + + // 250 ms loop at 48 kHz. Repeated wrapped windows stress compact/insert paths + // under heavy wraparound without requiring any extra metadata. + loop.EndRecord(12000u, 0u); + for (std::uint32_t i = 0u; i < 200u; ++i) + { + const auto start = (i * 73u) % 12000u; + const auto duration = 38400u; // 800 ms equivalent at 48 kHz. + const auto value = static_cast(i % 97u) / 96.0f; + loop.OverwriteAutomationWindow(*lane, start, duration, value); + } + + std::array, midi::AutomationLane::MaxPoints> points{}; + const auto count = loop.SnapshotAutomationLanePoints(*lane, points.data(), points.size()); + ASSERT_LE(count, midi::AutomationLane::MaxPoints); + + for (std::size_t i = 1u; i < count; ++i) + EXPECT_LE(points[i - 1u].first, points[i].first); +} From b8ae9afca7104960ea336d7045b7be453b5b1868 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 10:20:18 +0100 Subject: [PATCH 19/27] Implement seqlock mechanism for safe concurrent access to automation points --- JammaLib/src/midi/MidiLoop.cpp | 90 +++++++++++++++++++++++----------- JammaLib/src/midi/MidiLoop.h | 7 ++- 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/JammaLib/src/midi/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index 2f2ba6b4..7d1c63de 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -580,37 +580,69 @@ float MidiLoop::GetAutomationValueAtCursor(std::size_t laneIdx, double frac, std return 0.0f; const auto& lane = _lanes[laneIdx]; - const auto count = lane.PointCount; - if (0u == count) - return 0.0f; - - const auto& points = lane.Points; const auto fracF = static_cast(frac); - // Clamp cursor into range and reset on loop wrap (frac stepped backward). - if (cursorIdx >= count) - cursorIdx = 0u; - if (fracF < points[cursorIdx].first) - cursorIdx = 0u; - - // Advance forward while the next point still starts at or before frac. - while ((cursorIdx + 1u) < count && points[cursorIdx + 1u].first <= fracF) - ++cursorIdx; - - // Before the first point: hold the first value. At/after the last: hold last. - if (fracF <= points[0].first) - return points[0].second; - if ((cursorIdx + 1u) >= count) - return points[count - 1u].second; - - const auto& lo = points[cursorIdx]; - const auto& hi = points[cursorIdx + 1u]; - const auto span = hi.first - lo.first; - if (span <= 0.0f) - return hi.second; - - const auto t = (fracF - lo.first) / span; - return lo.second + t * (hi.second - lo.second); + // Seqlock read: retry while the MIDI-thread writer holds the lock (odd generation) + // or the buffer shifted under us. Bounded to avoid blocking the audio path; on + // give-up the cursor is left unchanged and the last committed value is returned. + for (int attempt = 0; attempt < 8; ++attempt) + { + const auto gen0 = lane.Revision.load(std::memory_order_acquire); + if (gen0 & 1u) + continue; // Writer in progress — spin once more. + + const auto count = lane.PointCount; + if (0u == count) + { + const auto gen1 = lane.Revision.load(std::memory_order_acquire); + if (gen0 == gen1) + return 0.0f; + continue; + } + + const auto& points = lane.Points; + + // Stage cursor updates locally; only commit if the generation validates. + auto localCursor = cursorIdx; + + // Clamp cursor into range and reset on loop wrap (frac stepped backward). + if (localCursor >= count) + localCursor = 0u; + if (fracF < points[localCursor].first) + localCursor = 0u; + + // Advance forward while the next point still starts at or before frac. + while ((localCursor + 1u) < count && points[localCursor + 1u].first <= fracF) + ++localCursor; + + // Before the first point: hold the first value. At/after the last: hold last. + float result; + if (fracF <= points[0].first) + { + result = points[0].second; + } + else if ((localCursor + 1u) >= count) + { + result = points[count - 1u].second; + } + else + { + const auto lo = points[localCursor]; + const auto hi = points[localCursor + 1u]; + const auto span = hi.first - lo.first; + result = (span <= 0.0f) ? hi.second + : lo.second + (fracF - lo.first) / span * (hi.second - lo.second); + } + + const auto gen1 = lane.Revision.load(std::memory_order_acquire); + if (gen0 == gen1) + { + cursorIdx = static_cast(localCursor); + return result; + } + } + + return 0.0f; } void MidiLoop::ClearAutomationLane(std::size_t laneIdx) noexcept diff --git a/JammaLib/src/midi/MidiLoop.h b/JammaLib/src/midi/MidiLoop.h index 0f0e0378..fde25a41 100644 --- a/JammaLib/src/midi/MidiLoop.h +++ b/JammaLib/src/midi/MidiLoop.h @@ -107,10 +107,9 @@ namespace midi // Seqlock generation counter. The MIDI thread (the only writer of Points / // PointCount) bumps this to an odd value before mutating the buffer and back - // to an even value afterwards. The render thread reads the points with a - // retry loop so it never observes a half-shifted buffer. Audio-thread cursor - // reads ignore this; a momentarily torn read there is harmless and pre-dates - // the display path. + // to an even value afterwards. Both the audio thread (GetAutomationValueAtCursor) + // and the render thread (SnapshotAutomationLanePoints) participate in a bounded + // retry loop so neither ever observes a half-shifted buffer. std::atomic Revision{ 0u }; }; From 084836419606f2dec1c5284b5ee795714acb8a3f Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 10:27:18 +0100 Subject: [PATCH 20/27] Refactor automation dispatch to separate playback state and ensure immutability after publication --- JammaLib/src/engine/Station.cpp | 27 +++++++++++++++++++-------- JammaLib/src/engine/Station.h | 22 +++++++++++++++++++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/JammaLib/src/engine/Station.cpp b/JammaLib/src/engine/Station.cpp index b2cdbc6e..0c82dafc 100644 --- a/JammaLib/src/engine/Station.cpp +++ b/JammaLib/src/engine/Station.cpp @@ -492,8 +492,6 @@ void Station::RebuildAutomationDispatch() entry.laneIdx = static_cast(laneIdx); entry.loopPhaseAnchor = midiLoop->LoopPhaseAnchor(); entry.loopLengthSamps = midiLoop->LoopLengthSamps(); - entry.cursorIdx = 0u; - entry.lastValue = -2.0f; // force first write after a rebuild ++count; } } @@ -502,7 +500,7 @@ void Station::RebuildAutomationDispatch() _automationDispatchCount[back] = count; // Release pairs with the audio thread's acquire load: makes all MIDI-thread // writes to lane Points visible before the new list is consumed. - _automationDispatch.store(buf, std::memory_order_release); + _automationDispatch.store(static_cast(buf), std::memory_order_release); _automationDispatchBack ^= 1u; } @@ -557,17 +555,29 @@ std::shared_ptr Station::ResolveEditorAutomationLoop(const vst:: void Station::_RunAutomationDispatch(std::uint32_t blockStartSample, std::uint32_t numSamps) noexcept { - auto* dispatches = _automationDispatch.load(std::memory_order_acquire); + const auto* dispatches = _automationDispatch.load(std::memory_order_acquire); if (!dispatches) return; + // Detect a newly published list and reset per-entry playback state so + // stale cursors and last-values from the previous list are never used. + if (dispatches != _automationDispatchFront) + { + _automationDispatchFront = dispatches; + for (auto& s : _automationPlaybackState) + { + s.cursorIdx = 0u; + s.lastValue = -2.0f; + } + } + const std::uint8_t frontIdx = (dispatches == _automationDispatchBuf[0]) ? 0u : 1u; const auto count = _automationDispatchCount[frontIdx]; const auto dispatchSample = blockStartSample + ((numSamps > 0u) ? (numSamps - 1u) : 0u); for (std::uint8_t i = 0u; i < count; ++i) { - auto& entry = dispatches[i]; + const auto& entry = dispatches[i]; if (!entry.plugin || !entry.loop) continue; @@ -586,12 +596,13 @@ void Station::_RunAutomationDispatch(std::uint32_t blockStartSample, / static_cast(entry.loopLengthSamps) : 0.0; - const float val = entry.loop->GetAutomationValueAtCursor(entry.laneIdx, frac, entry.cursorIdx); + auto& state = _automationPlaybackState[i]; + const float val = entry.loop->GetAutomationValueAtCursor(entry.laneIdx, frac, state.cursorIdx); - if (std::abs(val - entry.lastValue) > AutomationEpsilon) + if (std::abs(val - state.lastValue) > AutomationEpsilon) { entry.plugin->SetParameter(entry.paramIdx, val); - entry.lastValue = val; + state.lastValue = val; } } } diff --git a/JammaLib/src/engine/Station.h b/JammaLib/src/engine/Station.h index d94e0a8f..89b32e1e 100644 --- a/JammaLib/src/engine/Station.h +++ b/JammaLib/src/engine/Station.h @@ -278,6 +278,8 @@ namespace engine // One pre-resolved automation mapping ready for the audio thread. All // pointer chasing and lane metadata are baked in by RebuildAutomationDispatch // so the audio path is a flat loop over a hot cache line. + // IMMUTABLE after publication: the audio thread must never write into this + // struct, which is what makes the double-buffer scheme race-free. struct AutomationDispatch { vst::IVstPlugin* plugin = nullptr; // raw observer — lifetime owned by VstChain @@ -286,8 +288,15 @@ namespace engine std::uint8_t laneIdx = 0u; // which lane within loop to read std::uint32_t loopLengthSamps = 0u; // pre-resolved; avoids per-block takes lock std::uint32_t loopPhaseAnchor = 0u; // global sample mapping to loop position 0 - std::uint16_t cursorIdx = 0u; // playback cursor for amortised O(1) interpolation - float lastValue = -2.0f; // sentinel: force first write + }; + // Per-entry playback state owned exclusively by the audio thread. + // Kept separate from AutomationDispatch so the dispatch buffers are + // immutable after publication; the writer can safely overwrite the + // back buffer without racing the audio callback's cursor/value updates. + struct AutomationPlaybackState + { + std::uint16_t cursorIdx = 0u; + float lastValue = -2.0f; // sentinel: force first write }; static constexpr std::size_t MaxAutomationDispatches = 64u; static constexpr float AutomationEpsilon = 1.0f / 65536.0f; // below 16-bit param resolution @@ -327,10 +336,17 @@ namespace engine // Flat automation dispatch list, double-buffered and published with an // atomic-swap release store (audio thread reads with acquire). Built only on // the non-audio thread in RebuildAutomationDispatch. - std::atomic _automationDispatch{ nullptr }; + // The buffers are immutable once published — the audio thread never writes + // into them, so the writer can safely fill the back buffer at any time. + std::atomic _automationDispatch{ nullptr }; AutomationDispatch _automationDispatchBuf[2][MaxAutomationDispatches]{}; std::uint8_t _automationDispatchCount[2]{}; std::uint8_t _automationDispatchBack = 0u; + // Audio-thread-only playback state, parallel to the active dispatch list. + // Reset whenever _RunAutomationDispatch detects a new list was published. + AutomationPlaybackState _automationPlaybackState[MaxAutomationDispatches]{}; + // Last dispatch pointer seen by the audio thread; used to detect rebuilds. + const AutomationDispatch* _automationDispatchFront = nullptr; // VST insert chain applied after all LoopTakes are mixed down, // just before each channel is sent to the output AudioMixer. From 5c4268ba32806e73b53813139c1303fd706ab2ca Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 11:00:42 +0100 Subject: [PATCH 21/27] Remove *ForTest functions in production code --- JammaLib/src/engine/Scene.cpp | 10 - JammaLib/src/engine/Scene.h | 3 - JammaLib/src/engine/Station.h | 8 - JammaLib/src/gui/GuiSelector.cpp | 5 - JammaLib/src/gui/GuiSelector.h | 1 - JammaLib/src/io/IoInputSubsystem.h | 2 - JammaLib/src/midi/MidiRouter.cpp | 96 -- JammaLib/src/midi/MidiRouter.h | 18 - JammaLib/src/timing/TimingQuantiser.cpp | 10 - JammaLib/src/timing/TimingQuantiser.h | 3 - JammaLib/src/vst/Vst2Plugin.h | 10 - test/JammaLib_Tests/JammaLib_Tests.vcxproj | 2 - .../engine/StationMidiInstrument_Tests.cpp | 233 +---- .../src/engine/Trigger_Tests.cpp | 482 +--------- .../src/gui/GuiControls_Tests.cpp | 53 -- .../src/midi/MidiLoop_Tests.cpp | 900 +----------------- .../midi/MidiRouterAutomationKey_Tests.cpp | 83 -- .../VstEditorAutomationSuppression_Tests.cpp | 97 -- .../src/vst/Vst2Plugin_Tests.cpp | 16 - 19 files changed, 3 insertions(+), 2029 deletions(-) delete mode 100644 test/JammaLib_Tests/src/midi/MidiRouterAutomationKey_Tests.cpp delete mode 100644 test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp diff --git a/JammaLib/src/engine/Scene.cpp b/JammaLib/src/engine/Scene.cpp index 2a4bb579..4fb01ccf 100644 --- a/JammaLib/src/engine/Scene.cpp +++ b/JammaLib/src/engine/Scene.cpp @@ -1135,16 +1135,6 @@ void Scene::InitResources(resources::ResourceLib& resourceLib, bool forceInit) } } -int Scene::CtrlOverlayVisibleButtonCountForTest() const noexcept -{ - return _quantisationInteraction.VisibleButtonCountForTest(); -} - -std::optional Scene::CtrlOverlayButtonCenterForTest(int buttonIndex) const noexcept -{ - return _quantisationInteraction.ButtonCenterForTest(buttonIndex); -} - glm::mat4 Scene::_View() { auto camPos = _camera.ModelPosition(); diff --git a/JammaLib/src/engine/Scene.h b/JammaLib/src/engine/Scene.h index c4555652..a206f169 100644 --- a/JammaLib/src/engine/Scene.h +++ b/JammaLib/src/engine/Scene.h @@ -190,9 +190,6 @@ namespace engine std::optional params) override; virtual void OnJobTick(Time curTime); virtual void InitResources(resources::ResourceLib& resourceLib, bool forceInit) override; - int CtrlOverlayVisibleButtonCountForTest() const noexcept; - std::optional CtrlOverlayButtonCenterForTest(int buttonIndex) const noexcept; - void InitReceivers(); void SetHover3d(std::vector path, base::Action::Modifiers modifiers); unsigned int Width() const { return _sizeParams.Size.Width; } diff --git a/JammaLib/src/engine/Station.h b/JammaLib/src/engine/Station.h index 89b32e1e..ce456f34 100644 --- a/JammaLib/src/engine/Station.h +++ b/JammaLib/src/engine/Station.h @@ -194,14 +194,6 @@ namespace engine // Non-audio (MIDI pump) thread only. std::shared_ptr ResolveEditorAutomationLoop(const vst::IVstPlugin* plugin) const; - // Test hook: run one automation dispatch block in isolation (drives - // SetParameter on wired plugins). Non-RT; mirrors the audio-thread path. - void RunAutomationDispatchForTest(std::uint32_t blockStartSample, - std::uint32_t numSamps = 128u) noexcept - { - _RunAutomationDispatch(blockStartSample, numSamps); - } - // Called on the job thread to actually perform the load / unload. virtual actions::ActionResult OnAction(actions::JobAction action) override; diff --git a/JammaLib/src/gui/GuiSelector.cpp b/JammaLib/src/gui/GuiSelector.cpp index c8a83b5c..0e7ea776 100644 --- a/JammaLib/src/gui/GuiSelector.cpp +++ b/JammaLib/src/gui/GuiSelector.cpp @@ -41,11 +41,6 @@ std::vector GuiSelector::CurrentHover() const return _currentHover; } -std::vector GuiSelector::PaintedPathForTest() const -{ - return _paintedPath; -} - bool GuiSelector::UpdateCurrentHover(std::vector path, Action::Modifiers modifiers, bool isSelected, diff --git a/JammaLib/src/gui/GuiSelector.h b/JammaLib/src/gui/GuiSelector.h index 8934612c..420f2e0b 100644 --- a/JammaLib/src/gui/GuiSelector.h +++ b/JammaLib/src/gui/GuiSelector.h @@ -41,7 +41,6 @@ namespace gui base::SelectDepth CurrentSelectDepth() const; void SetSelectDepth(base::SelectDepth level); std::vector CurrentHover() const; - std::vector PaintedPathForTest() const; bool UpdateCurrentHover(std::vector path, base::Action::Modifiers modifiers, bool isSelected, diff --git a/JammaLib/src/io/IoInputSubsystem.h b/JammaLib/src/io/IoInputSubsystem.h index c712db51..6a16ad85 100644 --- a/JammaLib/src/io/IoInputSubsystem.h +++ b/JammaLib/src/io/IoInputSubsystem.h @@ -47,8 +47,6 @@ namespace io void RegisterMidiTriggerRoute(const std::string& deviceName, std::shared_ptr trigger); - midi::MidiRouter& GetMidiRouterForTest() { return _midiRouter; } - private: static LRESULT CALLBACK _LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept; static HHOOK _globalInsertHook; diff --git a/JammaLib/src/midi/MidiRouter.cpp b/JammaLib/src/midi/MidiRouter.cpp index d69ae55a..4e1dffb7 100644 --- a/JammaLib/src/midi/MidiRouter.cpp +++ b/JammaLib/src/midi/MidiRouter.cpp @@ -187,11 +187,6 @@ bool MidiRouter::IsAutomationRecordHeld() noexcept return _automationRecordHeld.load(std::memory_order_acquire); } -void MidiRouter::SetAutomationRecordHeldForTest(bool held) noexcept -{ - _automationRecordHeld.store(held, std::memory_order_release); -} - void MidiRouter::_ResetEditorTouchStates() noexcept { for (auto& state : _editorTouchStates) @@ -276,17 +271,6 @@ void MidiRouter::RefreshAutomationSuppression(const vst::IVstPlugin* plugin, slot.ExpirySample.store(expirySample, std::memory_order_release); } -void MidiRouter::ResetAutomationSuppressionForTest() noexcept -{ - for (auto& slot : _automationSuppressions) - { - slot.Plugin.store(nullptr, std::memory_order_relaxed); - slot.ParamIndex.store(0u, std::memory_order_relaxed); - slot.ExpirySample.store(0u, std::memory_order_relaxed); - } - _automationSuppressionCount.store(0u, std::memory_order_release); -} - void MidiRouter::InitMidi(const io::UserConfig& cfg, const base::LoggingConfig& loggingConfig, std::atomic& audioSampleCounter, @@ -494,86 +478,6 @@ void MidiRouter::RegisterTrigger(const std::string& deviceName, std::shared_ptr< _PublishMidiTriggerRoutes(); } -void MidiRouter::RegisterTriggerForTest(const std::string& deviceName, - std::shared_ptr trigger, - std::uint8_t deviceSlot) -{ - if (!trigger) - return; - - _midiTriggerRoutes.push_back({ deviceName.empty() ? "default" : deviceName, deviceSlot, std::move(trigger) }); - _PublishMidiTriggerRoutes(); -} - -void MidiRouter::AddMidiInputDeviceForTest(const std::string& deviceName, std::uint8_t deviceSlot) -{ - auto currentInputs = _midiInputs.load(std::memory_order_acquire); - auto updatedInputs = std::make_shared>>(); - if (currentInputs) - updatedInputs->insert(updatedInputs->end(), currentInputs->begin(), currentInputs->end()); - - auto endpoint = std::make_shared(); - endpoint->DeviceSlot = deviceSlot; - endpoint->ConfiguredName = deviceName; - updatedInputs->push_back(endpoint); - - _midiInputs.store(updatedInputs, std::memory_order_release); -} - -void MidiRouter::PushMidiEventForTest(std::uint8_t deviceSlot, - std::uint8_t status, - std::uint8_t data1, - std::uint8_t data2) noexcept -{ - auto midiInputs = _midiInputs.load(std::memory_order_acquire); - if (!midiInputs) - return; - - for (const auto& input : *midiInputs) - { - if (!input || (input->DeviceSlot != deviceSlot)) - continue; - - midi::MidiEvent ingress{}; - ingress.sampleOffset = 0u; - ingress.status = status; - ingress.data1 = data1; - ingress.data2 = data2; - ingress._pad = 0u; - input->Ingress.Push(ingress); - break; - } -} - -bool MidiRouter::HasMidiInputDeviceForTest(std::uint8_t deviceSlot) const noexcept -{ - auto midiInputs = _midiInputs.load(std::memory_order_acquire); - if (!midiInputs) - return false; - - for (const auto& input : *midiInputs) - { - if (input && (input->DeviceSlot == deviceSlot)) - return true; - } - - return false; -} - -void MidiRouter::PushSerialTriggerEventForTest(const io::SerialTriggerEvent& event) -{ - std::scoped_lock lock(_serialIngressMutex); - _serialIngress.Push(event); -} - -MidiRouter::TriggerDispatchSummary MidiRouter::DispatchMidiTriggerEventForTest(std::uint8_t deviceSlot, - const midi::MidiEvent& event, - const io::UserConfig& userConfig, - const audio::AudioStreamParams& audioParams) -{ - return _DispatchMidiTriggerEvent(deviceSlot, event, userConfig, audioParams); -} - void MidiRouter::_ConsumeEditorAutomation(const std::vector>& stations, std::uint64_t globalSampleNow, const audio::AudioStreamParams& audioParams) noexcept diff --git a/JammaLib/src/midi/MidiRouter.h b/JammaLib/src/midi/MidiRouter.h index 04d55421..5d00c8eb 100644 --- a/JammaLib/src/midi/MidiRouter.h +++ b/JammaLib/src/midi/MidiRouter.h @@ -65,20 +65,6 @@ namespace midi void InitSerial(const io::UserConfig& cfg); void CloseSerial(); void RegisterTrigger(const std::string& deviceName, std::shared_ptr trigger); - void RegisterTriggerForTest(const std::string& deviceName, - std::shared_ptr trigger, - std::uint8_t deviceSlot); - void AddMidiInputDeviceForTest(const std::string& deviceName, std::uint8_t deviceSlot); - void PushMidiEventForTest(std::uint8_t deviceSlot, - std::uint8_t status, - std::uint8_t data1, - std::uint8_t data2) noexcept; - bool HasMidiInputDeviceForTest(std::uint8_t deviceSlot) const noexcept; - void PushSerialTriggerEventForTest(const io::SerialTriggerEvent& event); - TriggerDispatchSummary DispatchMidiTriggerEventForTest(std::uint8_t deviceSlot, - const midi::MidiEvent& event, - const io::UserConfig& userConfig, - const audio::AudioStreamParams& audioParams); TriggerDispatchSummary PumpMidi(const std::vector>& stations, std::uint64_t globalSampleNow, @@ -95,7 +81,6 @@ namespace midi const std::shared_ptr& hoveredTake); static bool IsAutomationRecordHeld() noexcept; - static void SetAutomationRecordHeldForTest(bool held) noexcept; // --- Editor-driven automation feedback suppression --- // Per (plugin, parameter) cool-down published by the non-RT MIDI pump and @@ -122,9 +107,6 @@ namespace midi std::uint32_t nowSample, std::uint32_t expirySample) noexcept; - // Test hook: drop all suppression entries. - static void ResetAutomationSuppressionForTest() noexcept; - private: static constexpr std::uint8_t UnresolvedMidiDeviceSlot = 0xffu; diff --git a/JammaLib/src/timing/TimingQuantiser.cpp b/JammaLib/src/timing/TimingQuantiser.cpp index 855b96ab..cb1214ec 100644 --- a/JammaLib/src/timing/TimingQuantiser.cpp +++ b/JammaLib/src/timing/TimingQuantiser.cpp @@ -930,16 +930,6 @@ std::optional TimingQuantiserController::TryHandleTouchMove(TouchM return std::nullopt; } -int TimingQuantiserController::VisibleButtonCountForTest() const noexcept -{ - return _overlay.VisibleButtonCount(); -} - -std::optional TimingQuantiserController::ButtonCenterForTest(int buttonIndex) const noexcept -{ - return _overlay.ButtonCenter(buttonIndex); -} - void TimingQuantiserController::_CaptureContext(const QuantisationInteractionContext& context, const ChildResolver& childResolver) { diff --git a/JammaLib/src/timing/TimingQuantiser.h b/JammaLib/src/timing/TimingQuantiser.h index eacb3d89..e7224a4d 100644 --- a/JammaLib/src/timing/TimingQuantiser.h +++ b/JammaLib/src/timing/TimingQuantiser.h @@ -327,9 +327,6 @@ namespace timing std::optional TryHandleTouchMove(actions::TouchMoveAction action, unsigned int sampleRate); - int VisibleButtonCountForTest() const noexcept; - std::optional ButtonCenterForTest(int buttonIndex) const noexcept; - private: enum class MidiPhaseDragTargetKind : std::uint8_t { diff --git a/JammaLib/src/vst/Vst2Plugin.h b/JammaLib/src/vst/Vst2Plugin.h index 80f55140..0776472d 100644 --- a/JammaLib/src/vst/Vst2Plugin.h +++ b/JammaLib/src/vst/Vst2Plugin.h @@ -125,16 +125,6 @@ namespace vst (sv == "sendVstMidiEvent"); } - static VstIntPtr HostCallbackForTest(AEffect* effect, - VstInt32 opcode, - VstInt32 index, - VstIntPtr value, - void* ptr, - float opt) - { - return HostCallback(effect, opcode, index, value, ptr, opt); - } - private: #ifdef JAMMA_VST2_ENABLED static constexpr size_t MaxMidiEventsPerBlock = 256u; diff --git a/test/JammaLib_Tests/JammaLib_Tests.vcxproj b/test/JammaLib_Tests/JammaLib_Tests.vcxproj index 698dc5d3..9c7266eb 100644 --- a/test/JammaLib_Tests/JammaLib_Tests.vcxproj +++ b/test/JammaLib_Tests/JammaLib_Tests.vcxproj @@ -187,9 +187,7 @@ xcopy /y "$(VcpkgInstalledDir)$(VcpkgTriplet)\debug\bin\gtest_main.dll" "$(OutDi - - diff --git a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp index 4ae295c8..48b2bbdd 100644 --- a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp +++ b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp @@ -1,4 +1,4 @@ -#include "gtest/gtest.h" +#include "gtest/gtest.h" #include @@ -534,234 +534,3 @@ TEST(StationMidiInstrument, PunchBoundariesEmitLiveMidiTransitionsForSourceAndLi EXPECT_TRUE(plugin->RealtimeFlags[0]); EXPECT_TRUE(plugin->RealtimeFlags[1]); } - -TEST(StationAutomation, WiredLaneDrivesPluginSetParameterDuringPlayback) -{ - auto station = MakeStation("station-automation"); - auto plugin = AddPlugin(station, L"fake-automation.dll"); - auto take = MakeMidiTake("automation-take"); - station->AddTake(take); - station->CommitChanges(); - - // Establish a recorded MIDI loop with a known length so frac mapping is defined. - take->Record({}, station->Name(), { 0u }, { "Keys" }); - take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); - take->Play(0u, 128u, 0u); - - ASSERT_EQ(1u, take->GetMidiLoops().size()); - auto midiLoop = take->GetMidiLoops()[0]; - ASSERT_NE(nullptr, midiLoop); - ASSERT_GT(midiLoop->LoopLengthSamps(), 0u); - - // Record two control points: 0.25 at frac 0, 0.75 at frac ~0.5. - const std::size_t laneIdx = 0u; - midiLoop->SetAutomationValueAtFrac(laneIdx, 0.0, 0.25f); - midiLoop->SetAutomationValueAtFrac(laneIdx, 0.5, 0.75f); - - auto& lane = midiLoop->GetLane(laneIdx); - lane.Mapping.TargetPlugin = plugin.get(); - lane.Mapping.TargetParameterIndex = 3u; - lane.Mapping.MatchKey.store( - midi::AutomationMapping::MakeMatchKey(0u, 7u), - std::memory_order_relaxed); - - station->RebuildAutomationDispatch(); - - // Dispatch should sample the latest value inside the block, not the block start. - const auto quarterLen = (std::max)(1u, midiLoop->LoopLengthSamps() / 4u); - station->RunAutomationDispatchForTest(0u, quarterLen); - ASSERT_GE(plugin->ParamSetCalls, 1u); - EXPECT_EQ(3u, plugin->LastParamIndex); - EXPECT_GT(plugin->LastParamValue, 0.25f); - EXPECT_LT(plugin->LastParamValue, 0.75f); - - // Half-way through the loop the interpolated value should approach 0.75. - const auto halfLen = midiLoop->LoopLengthSamps() / 2u; - station->RunAutomationDispatchForTest(halfLen, quarterLen); - EXPECT_NEAR(0.75f, plugin->LastParamValue, 1.0e-2f); -} - -TEST(StationAutomation, DitchTakeRemovesAutomationPlayback) -{ - auto station = MakeStation("station-automation-ditch"); - auto plugin = AddPlugin(station, L"fake-automation-ditch.dll"); - auto take = MakeMidiTake("automation-ditch-take"); - station->AddTake(take); - station->CommitChanges(); - - take->Record({}, station->Name(), { 0u }, { "Keys" }); - take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); - take->Play(0u, 128u, 0u); - - ASSERT_EQ(1u, take->GetMidiLoops().size()); - auto midiLoop = take->GetMidiLoops()[0]; - ASSERT_NE(nullptr, midiLoop); - - const auto laneIdx = 0u; - midiLoop->SetAutomationValueAtFrac(laneIdx, 0.0, 0.25f); - midiLoop->SetAutomationValueAtFrac(laneIdx, 0.5, 0.75f); - - auto& lane = midiLoop->GetLane(laneIdx); - lane.Mapping.TargetPlugin = plugin.get(); - lane.Mapping.TargetParameterIndex = 3u; - lane.Mapping.MatchKey.store( - midi::AutomationMapping::MakeEditorMatchKey(), - std::memory_order_relaxed); - - station->RebuildAutomationDispatch(); - - const auto loopLen = midiLoop->LoopLengthSamps(); - ASSERT_GT(loopLen, 0u); - const auto quarterLen = (std::max)(1u, loopLen / 4u); - const auto halfLen = loopLen / 2u; - - station->RunAutomationDispatchForTest(0u, quarterLen); - ASSERT_GE(plugin->ParamSetCalls, 1u); - const auto callsBeforeDitch = plugin->ParamSetCalls; - - actions::TriggerAction ditch; - ditch.ActionType = actions::TriggerAction::TRIGGER_DITCH; - ditch.TargetId = take->Id(); - station->OnAction(ditch); - - station->RunAutomationDispatchForTest(halfLen, quarterLen); - EXPECT_EQ(callsBeforeDitch, plugin->ParamSetCalls); - EXPECT_TRUE(take->GetMidiLoops().empty()); -} - -TEST(StationAutomation, RecordHeldDoesNotSuppressPlaybackWithoutParameterCooldown) -{ - auto station = MakeStation("station-automation-record"); - auto plugin = AddPlugin(station, L"fake-automation-record.dll"); - auto take = MakeMidiTake("automation-record-take"); - station->AddTake(take); - station->CommitChanges(); - - take->Record({}, station->Name(), { 0u }, { "Keys" }); - take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); - take->Play(0u, 128u, 0u); - - auto midiLoop = take->GetMidiLoops()[0]; - ASSERT_NE(nullptr, midiLoop); - midiLoop->SetAutomationValueAtFrac(0u, 0.0, 0.4f); - - auto& lane = midiLoop->GetLane(0u); - lane.Mapping.TargetPlugin = plugin.get(); - lane.Mapping.TargetParameterIndex = 1u; - lane.Mapping.MatchKey.store( - midi::AutomationMapping::MakeMatchKey(0u, 11u), - std::memory_order_relaxed); - station->RebuildAutomationDispatch(); - - // Holding automation record alone must not mute playback globally. - midi::MidiRouter::SetAutomationRecordHeldForTest(true); - station->RunAutomationDispatchForTest(0u); - EXPECT_GE(plugin->ParamSetCalls, 1u); - EXPECT_EQ(1u, plugin->LastParamIndex); - EXPECT_NEAR(0.4f, plugin->LastParamValue, 1.0e-4f); - - // Releasing record keeps normal playback behavior. - midi::MidiRouter::SetAutomationRecordHeldForTest(false); - station->RunAutomationDispatchForTest(0u); - EXPECT_GE(plugin->ParamSetCalls, 1u); - EXPECT_EQ(1u, plugin->LastParamIndex); - EXPECT_NEAR(0.4f, plugin->LastParamValue, 1.0e-4f); -} - -TEST(StationAutomation, EditorSuppressionGatesPlaybackUntilCooldownExpires) -{ - midi::MidiRouter::ResetAutomationSuppressionForTest(); - midi::MidiRouter::SetAutomationRecordHeldForTest(false); - - auto station = MakeStation("station-automation-suppress"); - auto plugin = AddPlugin(station, L"fake-automation-suppress.dll"); - auto take = MakeMidiTake("automation-suppress-take"); - station->AddTake(take); - station->CommitChanges(); - - take->Record({}, station->Name(), { 0u }, { "Keys" }); - take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); - take->Play(0u, 128u, 0u); - - auto midiLoop = take->GetMidiLoops()[0]; - ASSERT_NE(nullptr, midiLoop); - midiLoop->SetAutomationValueAtFrac(0u, 0.0, 0.6f); - - auto& lane = midiLoop->GetLane(0u); - lane.Mapping.TargetPlugin = plugin.get(); - lane.Mapping.TargetParameterIndex = 2u; - lane.Mapping.MatchKey.store( - midi::AutomationMapping::MakeEditorMatchKey(), - std::memory_order_relaxed); - station->RebuildAutomationDispatch(); - - // A live editor drag has just published a cool-down for (plugin, param 2) - // expiring at sample 5000. Record is NOT held, but playback for that one - // parameter must still be held off so the recorded curve doesn't fight the drag. - midi::MidiRouter::RefreshAutomationSuppression(plugin.get(), 2u, /*now*/ 0u, /*expiry*/ 5000u); - - station->RunAutomationDispatchForTest(0u); - EXPECT_EQ(0u, plugin->ParamSetCalls); - - // Past the cool-down deadline, playback resumes normally. - station->RunAutomationDispatchForTest(6000u); - EXPECT_GE(plugin->ParamSetCalls, 1u); - EXPECT_EQ(2u, plugin->LastParamIndex); - midi::MidiRouter::ResetAutomationSuppressionForTest(); -} - - -TEST(StationAutomation, EditorTouchWritesHoldWindowOnlyOnFreshTouch) -{ - midi::MidiRouter::ResetAutomationSuppressionForTest(); - midi::MidiRouter::SetAutomationRecordHeldForTest(true); - - auto station = MakeStation("station-editor-touch"); - auto plugin = AddPlugin(station, L"fake-editor-touch.dll"); - auto take = MakeMidiTake("editor-touch-take"); - station->AddTake(take); - station->CommitChanges(); - - take->Record({}, station->Name(), { 0u }, { "Keys" }); - take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 0u, 48u, 100u), "Keys", 0u); - take->Play(0u, 1000u, 0u); - - auto midiLoop = take->GetMidiLoops()[0]; - ASSERT_NE(nullptr, midiLoop); - ASSERT_GE(midiLoop->LoopLengthSamps(), 180u); - - io::UserConfig cfg{}; - audio::AudioStreamParams audioParams{}; - audioParams.SampleRate = 100u; - midi::MidiRouter router; - - vst::_lastTouchedParam.Plugin.store(plugin.get(), std::memory_order_relaxed); - vst::_lastTouchedParam.ParameterIndex.store(5u, std::memory_order_relaxed); - vst::_lastTouchedParam.Value.store(0.3f, std::memory_order_relaxed); - vst::_lastTouchedParam.Sequence.fetch_add(1u, std::memory_order_release); - - router.PumpMidi({ station }, 100u, cfg, audioParams); - - const auto lane = midiLoop->ResolveAutomationLaneFor(plugin.get(), 5u); - ASSERT_TRUE(lane.has_value()); - EXPECT_EQ(midi::AutomationMapping::MakeEditorMatchKey(), - midiLoop->GetLane(*lane).Mapping.MatchKey.load(std::memory_order_relaxed)); - - std::array, midi::AutomationLane::MaxPoints> points{}; - const auto firstCount = midiLoop->SnapshotAutomationLanePoints(*lane, points.data(), points.size()); - ASSERT_EQ(2u, firstCount); - EXPECT_NEAR(0.1f, points[0].first, 1.0e-6f); - EXPECT_NEAR(0.3f, points[0].second, 1.0e-6f); - EXPECT_NEAR(0.18f, points[1].first, 1.0e-6f); - EXPECT_NEAR(0.3f, points[1].second, 1.0e-6f); - EXPECT_TRUE(midi::MidiRouter::IsParameterSuppressed(plugin.get(), 5u, 179u)); - - // No new touch sequence: PumpMidi should neither add more points nor extend suppression. - router.PumpMidi({ station }, 150u, cfg, audioParams); - const auto secondCount = midiLoop->SnapshotAutomationLanePoints(*lane, points.data(), points.size()); - EXPECT_EQ(firstCount, secondCount); - EXPECT_FALSE(midi::MidiRouter::IsParameterSuppressed(plugin.get(), 5u, 181u)); - - midi::MidiRouter::SetAutomationRecordHeldForTest(false); - midi::MidiRouter::ResetAutomationSuppressionForTest(); -} diff --git a/test/JammaLib_Tests/src/engine/Trigger_Tests.cpp b/test/JammaLib_Tests/src/engine/Trigger_Tests.cpp index ba492e3d..6b98b64a 100644 --- a/test/JammaLib_Tests/src/engine/Trigger_Tests.cpp +++ b/test/JammaLib_Tests/src/engine/Trigger_Tests.cpp @@ -1,4 +1,4 @@ - + #include "gtest/gtest.h" #include #include "resources/ResourceLib.h" @@ -171,95 +171,10 @@ class TestScene : _AddStation(station); } - void RegisterMidiTriggerRouteForTest(const std::string& deviceName, - const std::shared_ptr& trigger, - std::uint8_t deviceSlot) - { - _inputSubsystem->GetMidiRouterForTest().RegisterTriggerForTest(deviceName, trigger, deviceSlot); - } - - void AddMidiInputDeviceForTest(const std::string& deviceName, - std::uint8_t deviceSlot) - { - _inputSubsystem->GetMidiRouterForTest().AddMidiInputDeviceForTest(deviceName, deviceSlot); - } - - void PushMainMidiEventForTest(std::uint8_t status, - std::uint8_t data1, - std::uint8_t data2, - unsigned int sampleRate = 0u) - { - (void)sampleRate; - if (!_inputSubsystem->GetMidiRouterForTest().HasMidiInputDeviceForTest(0u)) - AddMidiInputDeviceForTest("default", 0u); - - _inputSubsystem->GetMidiRouterForTest().PushMidiEventForTest(0u, status, data1, data2); - } - - void PushMidiEventForTest(std::uint8_t deviceSlot, - std::uint8_t status, - std::uint8_t data1, - std::uint8_t data2, - unsigned int sampleRate = 0u) - { - (void)sampleRate; - _inputSubsystem->GetMidiRouterForTest().PushMidiEventForTest(deviceSlot, status, data1, data2); - } - - void PumpMidiForTest() - { - _PumpMidi(); - } - - void DispatchMidiTriggerEventForTest(std::uint8_t deviceSlot, - const midi::MidiEvent& event) - { - auto audioParams = _audioEngine->GetStreamParams(); - auto summary = _inputSubsystem->GetMidiRouterForTest().DispatchMidiTriggerEventForTest(deviceSlot, event, _userConfig, audioParams); - if (summary.Activated) - _isSceneReset.store(false, std::memory_order_relaxed); - if (summary.Ditched) - { - unsigned int totalNumTakes = 0u; - for (auto& station : _stations) - { - station->CommitChanges(); - totalNumTakes += station->NumTakes(); - } - if (0u == totalNumTakes) - Reset(); - } - } - - void PushSerialTriggerEventForTest(const std::string& deviceName, - unsigned int buttonIndex, - bool isPressed) - { - _testSerialDeviceName = deviceName; - io::SerialTriggerEvent event{}; - event.Device = &_testSerialDeviceName; - event.ButtonIndex = buttonIndex; - event.IsPressed = isPressed; - _inputSubsystem->GetMidiRouterForTest().PushSerialTriggerEventForTest(event); - } - - void PumpSerialForTest() - { - _PumpSerial(); - } - - std::mutex& AudioMutexForTest() - { - return _sceneMutex; - } - bool IsSceneResetForTest() const { return _isSceneReset.load(std::memory_order_relaxed); } - -private: - std::string _testSerialDeviceName; }; std::shared_ptr MakeTestStation(const std::string& name = "station") @@ -919,185 +834,6 @@ TEST(Trigger, NoteOffMidiDitchBindingCompletesOnNextNoteOn) { EXPECT_EQ(TriggerAction::TRIGGER_DITCH_UNMUTE, receiver->Actions()[2].ActionType); } -TEST(Trigger, SharedMainMidiIngressStillRecordsLoopMidiWhenTriggerEatsEvent) { - auto receiver = std::make_shared(); - auto str = "{\"name\":\"TrigMidi\",\"stationtype\":0,\"midiinput\":[1],\"midiinputdevices\":[\"default\",\"Aux Keys\"],\"trigger\":{\"type\":\"midi\",\"device\":\"default\",\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60},\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"; - auto testStream = std::stringstream(str); - auto json = std::get(io::Json::FromStream(std::move(testStream)).value()); - auto trigStruct = io::RigFile::Trigger::FromJson(json); - ASSERT_TRUE(trigStruct.has_value()); - - TriggerParams trigParams; - trigParams.DebounceMs = 0u; - auto trigger = Trigger::FromFile(trigParams, trigStruct.value()); - ASSERT_TRUE(trigger.has_value()); - trigger.value()->SetReceiver(receiver); - - auto take = MakeTestLoopTake(); - take->Record({}, "station", { 0u }); - ASSERT_TRUE(take->IsArmed()); - - auto station = MakeTestStation(); - station->AddTake(take); - - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(station); - scene.RegisterMidiTriggerRouteForTest("default", trigger.value(), 0u); - - scene.PushMainMidiEventForTest(midi::MidiEvent::NoteOn, 60u, 100u); - scene.PumpMidiForTest(); - - ASSERT_EQ(1u, take->MidiLoopEventCount()); - ASSERT_EQ(1u, receiver->Actions().size()); - EXPECT_EQ(TriggerAction::TRIGGER_REC_START, receiver->Actions()[0].ActionType); - ASSERT_EQ(2u, receiver->Actions()[0].MidiInputDevices.size()); - EXPECT_EQ(0, receiver->Actions()[0].MidiInputDevices[0].compare("default")); - EXPECT_EQ(0, receiver->Actions()[0].MidiInputDevices[1].compare("Aux Keys")); - EXPECT_TRUE(take->IsArmed()); -} - -TEST(Trigger, RoutedMidiTriggerIgnoresUnmatchedNoteAndCcEvents) { - auto receiver = std::make_shared(); - auto str = "{\"name\":\"TrigMidi\",\"stationtype\":0,\"trigger\":{\"type\":\"midi\",\"device\":\"default\",\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60},\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"; - auto testStream = std::stringstream(str); - auto json = std::get(io::Json::FromStream(std::move(testStream)).value()); - auto trigStruct = io::RigFile::Trigger::FromJson(json); - ASSERT_TRUE(trigStruct.has_value()); - - TriggerParams trigParams; - trigParams.DebounceMs = 0u; - auto trigger = Trigger::FromFile(trigParams, trigStruct.value()); - ASSERT_TRUE(trigger.has_value()); - trigger.value()->SetReceiver(receiver); - - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - scene.RegisterMidiTriggerRouteForTest("default", trigger.value(), 0u); - - std::ostringstream captured; - auto* oldBuf = std::cout.rdbuf(captured.rdbuf()); - - scene.PushMainMidiEventForTest(midi::MidiEvent::NoteOn, 61u, 100u); - scene.PushMainMidiEventForTest(0xB0u, 65u, 127u); - scene.PumpMidiForTest(); - - std::cout.flush(); - std::cout.rdbuf(oldBuf); - - EXPECT_TRUE(receiver->Actions().empty()); - EXPECT_TRUE(captured.str().empty()); -} - -TEST(Trigger, ConfiguredMidiTriggerDeviceUsesResolvedRouteSlot) { - auto receiver = std::make_shared(); - auto str = "{\"name\":\"TrigMidi\",\"stationtype\":0,\"trigger\":{\"type\":\"midi\",\"device\":\"TriggerPad\",\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60},\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"; - auto testStream = std::stringstream(str); - auto json = std::get(io::Json::FromStream(std::move(testStream)).value()); - auto trigStruct = io::RigFile::Trigger::FromJson(json); - ASSERT_TRUE(trigStruct.has_value()); - - TriggerParams trigParams; - trigParams.DebounceMs = 0u; - auto trigger = Trigger::FromFile(trigParams, trigStruct.value()); - ASSERT_TRUE(trigger.has_value()); - trigger.value()->SetReceiver(receiver); - - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - scene.RegisterMidiTriggerRouteForTest("TriggerPad", trigger.value(), 0u); - - scene.PushMainMidiEventForTest(midi::MidiEvent::NoteOn, 60u, 100u); - scene.PumpMidiForTest(); - - ASSERT_EQ(1u, receiver->Actions().size()); - EXPECT_EQ(TriggerAction::TRIGGER_REC_START, receiver->Actions()[0].ActionType); -} - -TEST(Trigger, InitMidiReportsUnmatchedLoopRecordMidiDevices) { - auto str = "{\"name\":\"TrigMidi\",\"stationtype\":0,\"midiinput\":[1],\"midiinputdevices\":[\"Aux Keys\"],\"trigger\":{\"type\":\"midi\",\"device\":\"TriggerPad\",\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60},\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"; - auto testStream = std::stringstream(str); - auto json = std::get(io::Json::FromStream(std::move(testStream)).value()); - auto trigStruct = io::RigFile::Trigger::FromJson(json); - ASSERT_TRUE(trigStruct.has_value()); - - TriggerParams trigParams; - trigParams.DebounceMs = 0u; - auto trigger = Trigger::FromFile(trigParams, trigStruct.value()); - ASSERT_TRUE(trigger.has_value()); - - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - userConfig.Midi.Devices.push_back({ "TriggerPad", false }); - TestScene scene(sceneParams, userConfig); - scene.RegisterMidiTriggerRouteForTest("TriggerPad", trigger.value(), 0u); - - std::ostringstream captured; - auto* oldBuf = std::cout.rdbuf(captured.rdbuf()); - - scene.InitMidi(); - - std::cout.flush(); - std::cout.rdbuf(oldBuf); - - EXPECT_NE(std::string::npos, captured.str().find("No active MIDI input matches loop-record device \"Aux Keys\"")); -} - -TEST(Trigger, SceneRoutesAndRecordsSameChannelAcrossMultipleMidiDevices) { - auto receiverA = std::make_shared(); - auto receiverB = std::make_shared(); - auto triggerAJson = std::stringstream("{\"name\":\"TrigA\",\"stationtype\":0,\"trigger\":{\"type\":\"midi\",\"device\":\"Keys A\",\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60},\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"); - auto triggerBJson = std::stringstream("{\"name\":\"TrigB\",\"stationtype\":0,\"trigger\":{\"type\":\"midi\",\"device\":\"Keys B\",\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60},\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"); - auto triggerAStruct = io::RigFile::Trigger::FromJson(std::get(io::Json::FromStream(std::move(triggerAJson)).value())); - auto triggerBStruct = io::RigFile::Trigger::FromJson(std::get(io::Json::FromStream(std::move(triggerBJson)).value())); - ASSERT_TRUE(triggerAStruct.has_value()); - ASSERT_TRUE(triggerBStruct.has_value()); - - TriggerParams trigParams; - trigParams.DebounceMs = 0u; - auto triggerA = Trigger::FromFile(trigParams, triggerAStruct.value()); - auto triggerB = Trigger::FromFile(trigParams, triggerBStruct.value()); - ASSERT_TRUE(triggerA.has_value()); - ASSERT_TRUE(triggerB.has_value()); - triggerA.value()->SetReceiver(receiverA); - triggerB.value()->SetReceiver(receiverB); - - auto take = MakeTestLoopTake(); - take->Record({}, "station", { 0u }, { "Keys A", "Keys B" }); - auto station = MakeTestStation(); - station->AddTake(take); - - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(station); - scene.AddMidiInputDeviceForTest("Keys A", 0u); - scene.AddMidiInputDeviceForTest("Keys B", 1u); - scene.RegisterMidiTriggerRouteForTest("Keys A", triggerA.value(), 0u); - scene.RegisterMidiTriggerRouteForTest("Keys B", triggerB.value(), 1u); - - scene.PushMidiEventForTest(0u, midi::MidiEvent::NoteOn, 60u, 100u); - scene.PushMidiEventForTest(1u, midi::MidiEvent::NoteOn, 60u, 100u); - scene.PumpMidiForTest(); - - ASSERT_EQ(1u, receiverA->Actions().size()); - ASSERT_EQ(1u, receiverB->Actions().size()); - EXPECT_EQ(1u, take->MidiLoopEventCount(0u)); - EXPECT_EQ(1u, take->MidiLoopEventCount(1u)); -} TEST(Trigger, KeySceneActionHitsAllMatchingTriggers) { SceneParams sceneParams{ base::DrawableParams(), @@ -1127,65 +863,6 @@ TEST(Trigger, KeySceneActionHitsAllMatchingTriggers) { EXPECT_EQ(1u, secondStation->NumTakes()); } -TEST(Trigger, SerialSceneEventHitsAllMatchingTriggers) { - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - - const auto triggerJson = std::string("{\"name\":\"TrigSerial\",\"stationtype\":0,\"pairs\":[{\"source\":\"serial\",\"device\":\"pedal-a\",\"activatedown\":0,\"activateup\":0,\"ditchdown\":1,\"ditchup\":1}]}"); - - auto firstStation = MakeTestStation("station-a"); - firstStation->AddTrigger(MakeTriggerFromRigJson(triggerJson)); - firstStation->AddTrigger(MakeTriggerFromRigJson(triggerJson)); - scene.AddStationForTest(firstStation); - - auto secondStation = MakeTestStation("station-b"); - secondStation->AddTrigger(MakeTriggerFromRigJson(triggerJson)); - scene.AddStationForTest(secondStation); - - scene.PushSerialTriggerEventForTest("pedal-a", 0u, true); - scene.PumpSerialForTest(); - - EXPECT_EQ(2u, firstStation->NumTakes()); - EXPECT_EQ(1u, secondStation->NumTakes()); -} - -TEST(Trigger, MidiTriggerRoutingHitsAllMatchingRoutes) { - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - - const auto triggerJson = std::string("{\"name\":\"TrigMidi\",\"stationtype\":0,\"trigger\":{\"type\":\"midi\",\"device\":\"default\",\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60},\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"); - - auto firstStation = MakeTestStation("station-a"); - auto firstTrigger = MakeTriggerFromRigJson(triggerJson); - auto secondTrigger = MakeTriggerFromRigJson(triggerJson); - firstStation->AddTrigger(firstTrigger); - firstStation->AddTrigger(secondTrigger); - scene.AddStationForTest(firstStation); - scene.RegisterMidiTriggerRouteForTest("default", firstTrigger, 0u); - scene.RegisterMidiTriggerRouteForTest("default", secondTrigger, 0u); - - auto secondStation = MakeTestStation("station-b"); - auto thirdTrigger = MakeTriggerFromRigJson(triggerJson); - secondStation->AddTrigger(thirdTrigger); - scene.AddStationForTest(secondStation); - scene.RegisterMidiTriggerRouteForTest("default", thirdTrigger, 0u); - - midi::MidiEvent event{}; - event.status = midi::MidiEvent::NoteOn; - event.data1 = 60u; - event.data2 = 100u; - scene.DispatchMidiTriggerEventForTest(0u, event); - - EXPECT_EQ(2u, firstStation->NumTakes()); - EXPECT_EQ(1u, secondStation->NumTakes()); -} - TEST(Trigger, TriggerFromFileRejectsInvalidMidiBindingSpecsFromNonJsonCallers) { io::RigFile::Trigger trigStruct{}; trigStruct.Name = "TrigMidi"; @@ -1215,47 +892,6 @@ TEST(Trigger, TriggerFromFileRejectsInvalidMidiBindingSpecsFromNonJsonCallers) { } // Regression: trigger-driven engine mutation from the job thread (MIDI/serial -// pumps) must not run concurrently with Scene::CommitChanges publication. -// Holding _sceneMutex while a pump runs proves the synchronisation point. -TEST(Trigger, PumpMidiBlocksWhileAudioMutexHeld) { - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - - const auto triggerJson = std::string("{\"name\":\"TrigMidi\",\"stationtype\":0,\"trigger\":{\"type\":\"midi\",\"device\":\"default\",\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60},\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"); - - auto station = MakeTestStation("station-a"); - auto trigger = MakeTriggerFromRigJson(triggerJson); - station->AddTrigger(trigger); - scene.AddStationForTest(station); - scene.RegisterMidiTriggerRouteForTest("default", trigger, 0u); - - scene.PushMainMidiEventForTest(midi::MidiEvent::NoteOn, 60u, 100u); - - std::atomic pumpFinished{ false }; - { - std::unique_lock holdLock(scene.AudioMutexForTest()); - - std::thread pumpThread([&] { - scene.PumpMidiForTest(); - pumpFinished.store(true, std::memory_order_release); - }); - - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - EXPECT_FALSE(pumpFinished.load(std::memory_order_acquire)) - << "PumpMidi must block while _sceneMutex is held by another thread"; - EXPECT_EQ(0u, station->NumTakes()) - << "Trigger dispatch must not occur until _sceneMutex is released"; - - holdLock.unlock(); - pumpThread.join(); - } - - EXPECT_TRUE(pumpFinished.load(std::memory_order_acquire)); - EXPECT_EQ(1u, station->NumTakes()); -} // ---- Scene reset tests ------------------------------------------------- // Tests 1-3: regression (key-trigger paths that already work). @@ -1404,119 +1040,3 @@ TEST(SceneReset, KeyTriggerDitchInOverdub_ResetsScene) { } // FAILS before fix: _DispatchMidiTriggerEvent never called Reset() when ditch -// reduced total takes to zero. -TEST(SceneReset, MidiTriggerDitchWhileRecording_ResetsScene) { - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - - const auto triggerJson = std::string( - "{\"name\":\"TrigMidi\",\"stationtype\":0,\"trigger\":{" - "\"type\":\"midi\",\"device\":\"default\"," - "\"activate\":{\"kind\":\"note\",\"channel\":1,\"id\":60}," - "\"ditch\":{\"kind\":\"cc\",\"channel\":1,\"id\":64}}}"); - - auto station = MakeTestStation(); - auto trigger = MakeTriggerFromRigJson(triggerJson); - station->AddTrigger(trigger); - scene.AddStationForTest(station); - scene.RegisterMidiTriggerRouteForTest("default", trigger, 0u); - - // MIDI activate: start recording - midi::MidiEvent noteOn{}; - noteOn.status = midi::MidiEvent::NoteOn; - noteOn.data1 = 60u; - noteOn.data2 = 100u; - scene.DispatchMidiTriggerEventForTest(0u, noteOn); - - EXPECT_EQ(1u, station->NumTakes()); - - // Commit so _TryGetTake can find the take by ID in _loopTakes - station->CommitChanges(); - - // MIDI ditch: CC down then up - midi::MidiEvent ccDown{ 0u, 0xB0u, 64u, 127u, 0u }; - midi::MidiEvent ccUp{ 0u, 0xB0u, 64u, 0u, 0u }; - scene.DispatchMidiTriggerEventForTest(0u, ccDown); - scene.DispatchMidiTriggerEventForTest(0u, ccUp); - - EXPECT_EQ(0u, station->NumTakes()); - EXPECT_TRUE(scene.IsSceneResetForTest()); -} - -// FAILS before fix: _PumpSerial never called Reset() on ditch-to-zero. -TEST(SceneReset, SerialTriggerDitchWhileRecording_ResetsScene) { - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - - const auto triggerJson = std::string( - "{\"name\":\"TrigSerial\",\"stationtype\":0,\"pairs\":[{" - "\"source\":\"serial\",\"device\":\"pedal-a\"," - "\"activatedown\":0,\"activateup\":0," - "\"ditchdown\":1,\"ditchup\":1}]}"); - - auto station = MakeTestStation(); - station->AddTrigger(MakeTriggerFromRigJson(triggerJson)); - scene.AddStationForTest(station); - - // Serial activate: button 0 pressed - scene.PushSerialTriggerEventForTest("pedal-a", 0u, true); - scene.PumpSerialForTest(); - - EXPECT_EQ(1u, station->NumTakes()); - - // Commit so _TryGetTake can find the take by ID in _loopTakes - station->CommitChanges(); - - // Serial ditch: button 1 down then up - scene.PushSerialTriggerEventForTest("pedal-a", 1u, true); - scene.PumpSerialForTest(); - scene.PushSerialTriggerEventForTest("pedal-a", 1u, false); - scene.PumpSerialForTest(); - - EXPECT_EQ(0u, station->NumTakes()); - EXPECT_TRUE(scene.IsSceneResetForTest()); -} - -TEST(Trigger, PumpSerialBlocksWhileAudioMutexHeld) { - SceneParams sceneParams{ base::DrawableParams(), - base::MoveableParams(), - base::SizeableParams() }; - io::UserConfig userConfig = {}; - TestScene scene(sceneParams, userConfig); - - const auto triggerJson = std::string("{\"name\":\"TrigSerial\",\"stationtype\":0,\"pairs\":[{\"source\":\"serial\",\"device\":\"pedal-a\",\"activatedown\":0,\"activateup\":0,\"ditchdown\":1,\"ditchup\":1}]}"); - - auto station = MakeTestStation("station-a"); - station->AddTrigger(MakeTriggerFromRigJson(triggerJson)); - scene.AddStationForTest(station); - - scene.PushSerialTriggerEventForTest("pedal-a", 0u, true); - - std::atomic pumpFinished{ false }; - { - std::unique_lock holdLock(scene.AudioMutexForTest()); - - std::thread pumpThread([&] { - scene.PumpSerialForTest(); - pumpFinished.store(true, std::memory_order_release); - }); - - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - EXPECT_FALSE(pumpFinished.load(std::memory_order_acquire)) - << "PumpSerial must block while _sceneMutex is held by another thread"; - EXPECT_EQ(0u, station->NumTakes()) - << "Trigger dispatch must not occur until _sceneMutex is released"; - - holdLock.unlock(); - pumpThread.join(); - } - - EXPECT_TRUE(pumpFinished.load(std::memory_order_acquire)); - EXPECT_EQ(1u, station->NumTakes()); -} diff --git a/test/JammaLib_Tests/src/gui/GuiControls_Tests.cpp b/test/JammaLib_Tests/src/gui/GuiControls_Tests.cpp index 0da26605..df7366b9 100644 --- a/test/JammaLib_Tests/src/gui/GuiControls_Tests.cpp +++ b/test/JammaLib_Tests/src/gui/GuiControls_Tests.cpp @@ -354,56 +354,3 @@ TEST(GuiSelector, ShiftKeyTogglesNoneAddMode) { selector->OnAction(ctrlDown); ASSERT_EQ(GuiSelector::SELECT_NONE, selector->CurrentMode()); } - -TEST(GuiSelector, PaintSelectionPersistsWhenHoverClears) { - GuiSelectorParams params; - auto selector = std::make_shared(params); - selector->SetSelectDepth(base::DEPTH_STATION); - - const std::vector hoveredPath = { 2, 4 }; - ASSERT_TRUE(selector->UpdateCurrentHover(hoveredPath, - base::Action::MODIFIER_SHIFT, - false, - base::Tweakable::TWEAKSTATE_NONE)); - - TouchAction down = MakeTouchAction(TouchAction::TOUCH_DOWN, { 5, 5 }); - down.Modifiers = base::Action::MODIFIER_SHIFT; - down.Index = 0; - selector->OnAction(down); - - ASSERT_EQ(hoveredPath, selector->PaintedPathForTest()); - ASSERT_EQ(GuiSelector::SELECT_SELECTADD, selector->CurrentMode()); - - selector->UpdateCurrentHover({}, - base::Action::MODIFIER_NONE, - false, - base::Tweakable::TWEAKSTATE_NONE); - ASSERT_EQ(hoveredPath, selector->PaintedPathForTest()); -} - -TEST(GuiSelector, HoverLossDoesNotClearCommittedPaintPath) { - GuiSelectorParams params; - auto selector = std::make_shared(params); - selector->SetSelectDepth(base::DEPTH_STATION); - - const std::vector hoveredPath = { 3 }; - ASSERT_TRUE(selector->UpdateCurrentHover(hoveredPath, - base::Action::MODIFIER_SHIFT, - false, - base::Tweakable::TWEAKSTATE_NONE)); - - TouchAction down = MakeTouchAction(TouchAction::TOUCH_DOWN, { 8, 8 }); - down.Modifiers = base::Action::MODIFIER_SHIFT; - down.Index = 0; - selector->OnAction(down); - - TouchAction up = MakeTouchAction(TouchAction::TOUCH_UP, { 8, 8 }); - up.Index = 0; - selector->OnAction(up); - - selector->UpdateCurrentHover({}, - base::Action::MODIFIER_NONE, - false, - base::Tweakable::TWEAKSTATE_NONE); - ASSERT_EQ(hoveredPath, selector->PaintedPathForTest()); -} diff --git a/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp index eb28a8cc..f37f026c 100644 --- a/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp +++ b/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include @@ -127,154 +127,6 @@ namespace return hoverPath; } - void ApplyCtrlDrag(TestScene& scene, - const utils::Position2d& start, - const utils::Position2d& finish, - int buttonIndex = 0) - { - auto ctrl = static_cast(base::Action::MODIFIER_CTRL); - - TouchMoveAction cursorMove; - cursorMove.Index = 0; - cursorMove.Position = start; - cursorMove.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(cursorMove); - - actions::KeyAction ctrlDown; - ctrlDown.KeyActionType = actions::KeyAction::KEY_DOWN; - ctrlDown.KeyChar = 17; - ctrlDown.Modifiers = ctrl; - scene.OnAction(ctrlDown); - - auto dragStart = start; - if (auto buttonCenter = scene.CtrlOverlayButtonCenterForTest(buttonIndex); buttonCenter.has_value()) - dragStart = buttonCenter.value(); - const auto dragDelta = finish - start; - const utils::Position2d dragFinish = { - dragStart.X + dragDelta.X, - dragStart.Y + dragDelta.Y - }; - - TouchAction down; - down.State = TouchAction::TOUCH_DOWN; - down.Index = 0; - down.Position = dragStart; - down.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(down).IsEaten); - - TouchMoveAction move; - move.Index = 0; - move.Position = dragFinish; - move.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(move).IsEaten); - - TouchAction up; - up.State = TouchAction::TOUCH_UP; - up.Index = 0; - up.Position = dragFinish; - up.Modifiers = ctrl; - EXPECT_FALSE(scene.OnAction(up).IsEaten); - - actions::KeyAction ctrlUp; - ctrlUp.KeyActionType = actions::KeyAction::KEY_UP; - ctrlUp.KeyChar = 17; - ctrlUp.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(ctrlUp); - } - - void ApplyCtrlFractionDrag(TestScene& scene, - const utils::Position2d& anchor, - const utils::Position2d& finish, - int buttonIndex) - { - auto ctrl = static_cast(base::Action::MODIFIER_CTRL); - - TouchMoveAction cursorMove; - cursorMove.Index = 0; - cursorMove.Position = anchor; - cursorMove.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(cursorMove); - - actions::KeyAction ctrlDown; - ctrlDown.KeyActionType = actions::KeyAction::KEY_DOWN; - ctrlDown.KeyChar = 17; - ctrlDown.Modifiers = ctrl; - scene.OnAction(ctrlDown); - - auto fractionCenter = scene.CtrlOverlayButtonCenterForTest(buttonIndex); - ASSERT_TRUE(fractionCenter.has_value()); - const auto dragDelta = finish - anchor; - const utils::Position2d dragFinish = { - fractionCenter->X + dragDelta.X, - fractionCenter->Y + dragDelta.Y - }; - - TouchAction down; - down.State = TouchAction::TOUCH_DOWN; - down.Index = 0; - down.Position = fractionCenter.value(); - down.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(down).IsEaten); - - TouchMoveAction move; - move.Index = 0; - move.Position = dragFinish; - move.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(move).IsEaten); - - TouchAction up; - up.State = TouchAction::TOUCH_UP; - up.Index = 0; - up.Position = dragFinish; - up.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(up).IsEaten); - - actions::KeyAction ctrlUp; - ctrlUp.KeyActionType = actions::KeyAction::KEY_UP; - ctrlUp.KeyChar = 17; - ctrlUp.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(ctrlUp); - } - - void ApplyCtrlFractionClick(TestScene& scene, - const utils::Position2d& anchor, - int buttonIndex) - { - auto ctrl = static_cast(base::Action::MODIFIER_CTRL); - - TouchMoveAction cursorMove; - cursorMove.Index = 0; - cursorMove.Position = anchor; - cursorMove.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(cursorMove); - - actions::KeyAction ctrlDown; - ctrlDown.KeyActionType = actions::KeyAction::KEY_DOWN; - ctrlDown.KeyChar = 17; - ctrlDown.Modifiers = ctrl; - scene.OnAction(ctrlDown); - - auto fractionCenter = scene.CtrlOverlayButtonCenterForTest(buttonIndex); - ASSERT_TRUE(fractionCenter.has_value()); - - TouchAction down; - down.State = TouchAction::TOUCH_DOWN; - down.Index = 0; - down.Position = fractionCenter.value(); - down.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(down).IsEaten); - - TouchAction up = down; - up.State = TouchAction::TOUCH_UP; - EXPECT_TRUE(scene.OnAction(up).IsEaten); - - actions::KeyAction ctrlUp; - ctrlUp.KeyActionType = actions::KeyAction::KEY_UP; - ctrlUp.KeyChar = 17; - ctrlUp.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(ctrlUp); - } - void AddRecordedLoopForVisual(std::shared_ptr take, const std::string& stationName, std::uint64_t transportStartSamps) @@ -922,84 +774,6 @@ TEST(LoopTakeMidiQuantisation, GuiActionTogglesQuantisation) { EXPECT_EQ(1600u, applied.GrainSamps); } -TEST(LoopTakeMidiQuantisation, CtrlDragEditsFraction) { - auto take = MakeLoopTake("fraction-drag-take"); - auto station = MakeStation("fraction-drag-station"); - station->AddTake(take); - - TestScene scene(SceneParams({ "" }, {}, { 640, 480 }), MakeSceneUserConfig()); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d(HoverPathFor(take), base::Action::MODIFIER_NONE); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - take->SetMidiQuantisation(settings); - - ApplyCtrlFractionDrag(scene, { 220, 220 }, { 220, 156 }, 1); - - const auto& moved = take->MidiQuantisation(); - EXPECT_TRUE(moved.Enabled); - EXPECT_EQ(MidiQuantisationFraction::Quarter, moved.Fraction); - EXPECT_EQ(1600u, moved.GrainSamps); -} - -TEST(LoopTakeMidiQuantisation, CtrlDragDoesNotInferLoopLengthAsGrainWhenSceneGrainUnknown) { - auto take = MakeLoopTake("fraction-unknown-grain-take"); - auto station = MakeStation("fraction-unknown-grain-station"); - station->AddTake(take); - - TestScene scene(SceneParams({ "" }, {}, { 640, 480 }), MakeSceneUserConfig()); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d(HoverPathFor(take), base::Action::MODIFIER_NONE); - - take->Record({}, "station", { 3u }); - take->Play(0u, 1600u, 0u); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 0u; - take->SetMidiQuantisation(settings); - - ApplyCtrlFractionDrag(scene, { 220, 220 }, { 220, 156 }, 1); - - const auto& moved = take->MidiQuantisation(); - EXPECT_TRUE(moved.Enabled); - EXPECT_EQ(MidiQuantisationFraction::Quarter, moved.Fraction); - EXPECT_EQ(0u, moved.GrainSamps); -} - -TEST(LoopTakeMidiQuantisation, CtrlClickTogglesEnableDisable) { - auto take = MakeLoopTake("fraction-click-take"); - auto station = MakeStation("fraction-click-station"); - station->AddTake(take); - - TestScene scene(SceneParams({ "" }, {}, { 640, 480 }), MakeSceneUserConfig()); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d(HoverPathFor(take), base::Action::MODIFIER_NONE); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Eighth; - settings.GrainSamps = 1600u; - take->SetMidiQuantisation(settings); - - ApplyCtrlFractionClick(scene, { 220, 220 }, 1); - EXPECT_TRUE(take->MidiQuantisation().Enabled); - EXPECT_EQ(MidiQuantisationFraction::Eighth, take->MidiQuantisation().Fraction); - - ApplyCtrlFractionClick(scene, { 220, 220 }, 1); - EXPECT_FALSE(take->MidiQuantisation().Enabled); -} - TEST(LoopTakeMidiQuantisation, TransportStartContributesNaturalPhaseOffset) { auto take = MakeLoopTake("take-phase-anchor"); @@ -1132,678 +906,6 @@ TEST(LoopTakeMidiQuantisation, QuantisationVisualPublishesResolvedPhase) { EXPECT_EQ(10u, visual->LoopGrains); } -TEST(LoopTakeMidiQuantisation, SceneCtrlDragBackgroundUpdatesGlobalPhase) { - TestScene scene(SceneParams({ "" }, {}, { 640, 480 }), MakeSceneUserConfig()); - auto station = MakeStation("scene-global-station"); - auto take = MakeLoopTake("scene-global-take"); - station->AddTake(take); - scene.AddStationForTest(station); - - const utils::Position2d start{ 20, 20 }; - const utils::Position2d finish{ 70, 20 }; - ApplyCtrlDrag(scene, start, finish); - - EXPECT_EQ(ExpectedPhaseOffsetForDrag(start, finish), station->GlobalPhaseOffsetSamps()); - EXPECT_EQ(ExpectedPhaseOffsetForDrag(start, finish), take->ResolvedMidiQuantisation().PhaseOffsetSamps); -} - -TEST(LoopTakeMidiQuantisation, SceneCtrlDragStationDepthUpdatesHoveredStationPhase) { - TestScene scene(SceneParams({ "" }, {}, { 640, 480 }), MakeSceneUserConfig()); - auto station = MakeStation("scene-station-phase"); - auto take = MakeLoopTake("scene-station-phase-take"); - station->AddTake(take); - scene.AddStationForTest(station); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d(HoverPathFor(station), base::Action::MODIFIER_NONE); - - const utils::Position2d start{ 20, 20 }; - const utils::Position2d finish{ 70, 20 }; - ApplyCtrlDrag(scene, start, finish); - - EXPECT_EQ(0, station->GlobalPhaseOffsetSamps()); - EXPECT_EQ(ExpectedPhaseOffsetForDrag(start, finish), station->StationPhaseOffsetSamps()); - EXPECT_EQ(ExpectedPhaseOffsetForDrag(start, finish), take->ResolvedMidiQuantisation().PhaseOffsetSamps); -} - -TEST(LoopTakeMidiQuantisation, SceneCtrlDragLoopTakeDepthUpdatesTakeLocalPhase) { - TestScene scene(SceneParams({ "" }, {}, { 640, 480 }), MakeSceneUserConfig()); - auto station = MakeStation("scene-take-phase-station"); - auto take = MakeLoopTake("scene-take-phase"); - station->AddTake(take); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d(HoverPathFor(take), base::Action::MODIFIER_NONE); - - const utils::Position2d start{ 20, 20 }; - const utils::Position2d finish{ 70, 20 }; - ApplyCtrlDrag(scene, start, finish); - - EXPECT_EQ(0, station->GlobalPhaseOffsetSamps()); - EXPECT_EQ(0, station->StationPhaseOffsetSamps()); - EXPECT_EQ(ExpectedPhaseOffsetForDrag(start, finish), take->MidiQuantisation().PhaseOffsetSamps); - EXPECT_EQ(ExpectedPhaseOffsetForDrag(start, finish), take->ResolvedMidiQuantisation().PhaseOffsetSamps); -} - -TEST(LoopTakeMidiQuantisation, VerboseUiLoggingOnlyPrintsOnFractionChanges) { - auto take = MakeLoopTake("verbose-fraction-take"); - auto station = MakeStation("verbose-fraction-station"); - station->AddTake(take); - - TestScene scene(SceneParams({ "" }, {}, { 640, 480 }), MakeSceneUserConfig()); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d(HoverPathFor(take), base::Action::MODIFIER_NONE); - - io::LoggingConfig logging; - logging.Ui = "verbose"; - take->SetLogging(logging); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - take->SetMidiQuantisation(settings); - - auto ctrl = static_cast(base::Action::MODIFIER_CTRL); - - std::ostringstream captured; - auto* oldBuf = std::cout.rdbuf(captured.rdbuf()); - - TouchMoveAction anchorMove; - anchorMove.Index = 0; - anchorMove.Position = { 220, 220 }; - anchorMove.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(anchorMove); - - actions::KeyAction ctrlDown; - ctrlDown.KeyActionType = actions::KeyAction::KEY_DOWN; - ctrlDown.KeyChar = 17; - ctrlDown.Modifiers = ctrl; - scene.OnAction(ctrlDown); - - auto fractionCenter = scene.CtrlOverlayButtonCenterForTest(1); - ASSERT_TRUE(fractionCenter.has_value()); - - TouchAction down; - down.State = TouchAction::TOUCH_DOWN; - down.Index = 0; - down.Position = fractionCenter.value(); - down.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(down).IsEaten); - - TouchMoveAction move = {}; - move.Index = 0; - move.Modifiers = ctrl; - - move.Position = { fractionCenter->X, fractionCenter->Y - 10 }; - EXPECT_TRUE(scene.OnAction(move).IsEaten); - - move.Position = { fractionCenter->X, fractionCenter->Y - 64 }; - EXPECT_TRUE(scene.OnAction(move).IsEaten); - - move.Position = { fractionCenter->X, fractionCenter->Y - 65 }; - EXPECT_TRUE(scene.OnAction(move).IsEaten); - - move.Position = { fractionCenter->X, fractionCenter->Y - 96 }; - EXPECT_TRUE(scene.OnAction(move).IsEaten); - - TouchAction up = down; - up.State = TouchAction::TOUCH_UP; - up.Position = move.Position; - EXPECT_TRUE(scene.OnAction(up).IsEaten); - - actions::KeyAction ctrlUp; - ctrlUp.KeyActionType = actions::KeyAction::KEY_UP; - ctrlUp.KeyChar = 17; - ctrlUp.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(ctrlUp); - - std::cout.flush(); - std::cout.rdbuf(oldBuf); - - const auto output = captured.str(); - EXPECT_NE(std::string::npos, output.find("1 -> 1/4")); - EXPECT_NE(std::string::npos, output.find("1/4 -> 1/8")); - EXPECT_EQ(std::string::npos, output.find("1 -> 1/2")); - EXPECT_EQ(std::string::npos, output.find("1/4 -> 1/4")); -} - -TEST(SceneInteractionRouting, ScenePhaseDragPropagatesToExistingLoopTakes) { - auto take = MakeLoopTake(); - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - take->SetMidiQuantisation(settings); - - auto station = MakeStation(); - station->AddTake(take); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d(HoverPathFor(take), base::Action::MODIFIER_NONE); - - ApplyCtrlDrag(scene, { 220, 220 }, { 284, 220 }); - - EXPECT_EQ(ExpectedPhaseOffsetForDrag({ 220, 220 }, { 284, 220 }), - take->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_FALSE(take->MidiQuantisation().Enabled); - EXPECT_EQ(MidiQuantisationFraction::Whole, take->MidiQuantisation().Fraction); -} - -TEST(SceneInteractionRouting, StationDepthMapsHoveredStationToLoopTakes) { - auto take = MakeLoopTake(); - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - take->SetMidiQuantisation(settings); - - auto station = MakeStation(); - station->AddTake(take); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d(HoverPathFor(take), base::Action::MODIFIER_NONE); - ApplyCtrlDrag(scene, { 220, 220 }, { 284, 220 }); - - EXPECT_EQ(ExpectedPhaseOffsetForDrag({ 220, 220 }, { 284, 220 }), - take->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_FALSE(take->MidiQuantisation().Enabled); - EXPECT_EQ(MidiQuantisationFraction::Whole, take->MidiQuantisation().Fraction); -} - -TEST(SceneInteractionRouting, StationDepthSelectionOverridesHoveredStationForPhaseDrag) { - auto hoveredTake = MakeLoopTake("take-a"); - auto selectedTake = MakeLoopTake("take-b"); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - hoveredTake->SetMidiQuantisation(settings); - selectedTake->SetMidiQuantisation(settings); - - auto hoveredStation = MakeStation("station-a"); - hoveredStation->AddTake(hoveredTake); - auto selectedStation = MakeStation("station-b"); - selectedStation->AddTake(selectedTake); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(hoveredStation); - scene.AddStationForTest(selectedStation); - scene.CommitChanges(); - selectedStation->Select(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d(HoverPathFor(hoveredTake), base::Action::MODIFIER_NONE); - - ApplyCtrlDrag(scene, { 220, 220 }, { 284, 220 }); - - const auto expectedPhase = ExpectedPhaseOffsetForDrag({ 220, 220 }, { 284, 220 }); - EXPECT_EQ(0, hoveredTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_EQ(expectedPhase, selectedTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_FALSE(hoveredTake->MidiQuantisation().Enabled); - EXPECT_FALSE(selectedTake->MidiQuantisation().Enabled); -} - -TEST(SceneInteractionRouting, LoopTakeDepthSelectionOverridesHoveredTakeForPhaseDrag) { - auto hoveredTake = MakeLoopTake("take-a"); - auto selectedTake = MakeLoopTake("take-b"); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - hoveredTake->SetMidiQuantisation(settings); - selectedTake->SetMidiQuantisation(settings); - - auto hoveredStation = MakeStation("station-a"); - hoveredStation->AddTake(hoveredTake); - auto selectedStation = MakeStation("station-b"); - selectedStation->AddTake(selectedTake); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(hoveredStation); - scene.AddStationForTest(selectedStation); - scene.CommitChanges(); - selectedTake->Select(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d(HoverPathFor(hoveredTake), base::Action::MODIFIER_NONE); - - ApplyCtrlDrag(scene, { 220, 220 }, { 284, 220 }); - - const auto expectedPhase = ExpectedPhaseOffsetForDrag({ 220, 220 }, { 284, 220 }); - EXPECT_EQ(0, hoveredTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_EQ(expectedPhase, selectedTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_FALSE(hoveredTake->MidiQuantisation().Enabled); - EXPECT_FALSE(selectedTake->MidiQuantisation().Enabled); -} - -TEST(SceneInteractionRouting, LoopDepthSelectionOverridesHoveredLoopForPhaseDrag) { - auto hoveredTake = MakeLoopTake("take-a"); - auto selectedTake = MakeLoopTake("take-b"); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - hoveredTake->SetMidiQuantisation(settings); - selectedTake->SetMidiQuantisation(settings); - - auto hoveredLoop = hoveredTake->AddLoop(0u, "station-a"); - auto selectedLoop = selectedTake->AddLoop(0u, "station-b"); - auto hoveredStation = MakeStation("station-a"); - hoveredStation->AddTake(hoveredTake); - auto selectedStation = MakeStation("station-b"); - selectedStation->AddTake(selectedTake); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(hoveredStation); - scene.AddStationForTest(selectedStation); - scene.CommitChanges(); - selectedLoop->Select(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOP); - scene.SetHover3d(HoverPathFor(hoveredLoop), base::Action::MODIFIER_NONE); - - ApplyCtrlDrag(scene, { 220, 220 }, { 284, 220 }); - - const auto expectedPhase = ExpectedPhaseOffsetForDrag({ 220, 220 }, { 284, 220 }); - EXPECT_EQ(0, hoveredTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_EQ(expectedPhase, selectedTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_FALSE(hoveredTake->MidiQuantisation().Enabled); - EXPECT_FALSE(selectedTake->MidiQuantisation().Enabled); -} - -TEST(SceneInteractionRouting, StationDepthNoHoverUsesGlobalPhaseAndAllStationDivisionTargets) { - auto firstTake = MakeLoopTake("station-nohover-take-a"); - auto secondTake = MakeLoopTake("station-nohover-take-b"); - auto firstStation = MakeStation("station-nohover-a"); - auto secondStation = MakeStation("station-nohover-b"); - firstStation->AddTake(firstTake); - secondStation->AddTake(secondTake); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - firstTake->SetMidiQuantisation(settings); - secondTake->SetMidiQuantisation(settings); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(firstStation); - scene.AddStationForTest(secondStation); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d({}, base::Action::MODIFIER_NONE); - - ApplyCtrlDrag(scene, { 220, 220 }, { 284, 220 }); - - const auto expectedPhase = ExpectedPhaseOffsetForDrag({ 220, 220 }, { 284, 220 }); - EXPECT_EQ(expectedPhase, firstStation->GlobalPhaseOffsetSamps()); - EXPECT_EQ(expectedPhase, secondStation->GlobalPhaseOffsetSamps()); - EXPECT_EQ(expectedPhase, firstTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_EQ(expectedPhase, secondTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - - ApplyCtrlFractionDrag(scene, { 220, 220 }, { 220, 156 }, 1); - EXPECT_TRUE(firstTake->MidiQuantisation().Enabled); - EXPECT_TRUE(secondTake->MidiQuantisation().Enabled); - EXPECT_EQ(MidiQuantisationFraction::Quarter, firstTake->MidiQuantisation().Fraction); - EXPECT_EQ(MidiQuantisationFraction::Quarter, secondTake->MidiQuantisation().Fraction); -} - -TEST(SceneInteractionRouting, LoopTakeDepthNoHoverUsesGlobalPhaseAndAllStationDivisionTargets) { - auto firstTake = MakeLoopTake("looptake-nohover-take-a"); - auto secondTake = MakeLoopTake("looptake-nohover-take-b"); - auto firstStation = MakeStation("looptake-nohover-a"); - auto secondStation = MakeStation("looptake-nohover-b"); - firstStation->AddTake(firstTake); - secondStation->AddTake(secondTake); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - firstTake->SetMidiQuantisation(settings); - secondTake->SetMidiQuantisation(settings); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(firstStation); - scene.AddStationForTest(secondStation); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d({}, base::Action::MODIFIER_NONE); - - ApplyCtrlDrag(scene, { 220, 220 }, { 284, 220 }); - - const auto expectedPhase = ExpectedPhaseOffsetForDrag({ 220, 220 }, { 284, 220 }); - EXPECT_EQ(expectedPhase, firstStation->GlobalPhaseOffsetSamps()); - EXPECT_EQ(expectedPhase, secondStation->GlobalPhaseOffsetSamps()); - EXPECT_EQ(expectedPhase, firstTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_EQ(expectedPhase, secondTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - - ApplyCtrlFractionDrag(scene, { 220, 220 }, { 220, 156 }, 1); - EXPECT_TRUE(firstTake->MidiQuantisation().Enabled); - EXPECT_TRUE(secondTake->MidiQuantisation().Enabled); - EXPECT_EQ(MidiQuantisationFraction::Quarter, firstTake->MidiQuantisation().Fraction); - EXPECT_EQ(MidiQuantisationFraction::Quarter, secondTake->MidiQuantisation().Fraction); -} - -TEST(SceneInteractionRouting, DragKeepsTargetsWhenSelectDepthChangesMidGesture) { - auto hoveredTake = MakeLoopTake("take-a"); - auto selectedTake = MakeLoopTake("take-b"); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - hoveredTake->SetMidiQuantisation(settings); - selectedTake->SetMidiQuantisation(settings); - - auto hoveredStation = MakeStation("station-a"); - hoveredStation->AddTake(hoveredTake); - auto selectedStation = MakeStation("station-b"); - selectedStation->AddTake(selectedTake); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(hoveredStation); - scene.AddStationForTest(selectedStation); - scene.CommitChanges(); - selectedStation->Select(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d(HoverPathFor(hoveredTake), base::Action::MODIFIER_NONE); - - auto ctrl = static_cast(base::Action::MODIFIER_CTRL); - - TouchMoveAction moveToAnchor; - moveToAnchor.Index = 0; - moveToAnchor.Position = { 220, 220 }; - moveToAnchor.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(moveToAnchor); - - actions::KeyAction ctrlDown; - ctrlDown.KeyActionType = actions::KeyAction::KEY_DOWN; - ctrlDown.KeyChar = 17; - ctrlDown.Modifiers = ctrl; - scene.OnAction(ctrlDown); - - auto globalPhaseCenter = scene.CtrlOverlayButtonCenterForTest(0); - ASSERT_TRUE(globalPhaseCenter.has_value()); - - TouchAction down; - down.State = TouchAction::TOUCH_DOWN; - down.Index = 0; - down.Position = globalPhaseCenter.value(); - down.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(down).IsEaten); - - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - - TouchMoveAction move; - move.Index = 0; - move.Position = { 284, 220 }; - move.Modifiers = ctrl; - EXPECT_TRUE(scene.OnAction(move).IsEaten); - - TouchAction up; - up.State = TouchAction::TOUCH_UP; - up.Index = 0; - up.Position = move.Position; - up.Modifiers = ctrl; - EXPECT_FALSE(scene.OnAction(up).IsEaten); - - actions::KeyAction ctrlUp; - ctrlUp.KeyActionType = actions::KeyAction::KEY_UP; - ctrlUp.KeyChar = 17; - ctrlUp.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(ctrlUp); - - const auto expectedPhase = ExpectedPhaseOffsetForDrag(down.Position, move.Position); - EXPECT_EQ(0, hoveredTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_EQ(expectedPhase, selectedTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_FALSE(hoveredTake->MidiQuantisation().Enabled); - EXPECT_FALSE(selectedTake->MidiQuantisation().Enabled); -} - -TEST(SceneInteractionRouting, CtrlOverlayLatchKeepsButtonContractAndAnchorWhileHeld) { - auto take = MakeLoopTake("overlay-latch-take"); - auto station = MakeStation("overlay-latch-station"); - station->AddTake(take); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d(HoverPathFor(take), base::Action::MODIFIER_NONE); - - TouchMoveAction moveToAnchor; - moveToAnchor.Index = 0; - moveToAnchor.Position = { 220, 220 }; - moveToAnchor.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(moveToAnchor); - - actions::KeyAction ctrlDown; - ctrlDown.KeyActionType = actions::KeyAction::KEY_DOWN; - ctrlDown.KeyChar = 17; - ctrlDown.Modifiers = base::Action::MODIFIER_CTRL; - scene.OnAction(ctrlDown); - - EXPECT_EQ(2, scene.CtrlOverlayVisibleButtonCountForTest()); - auto before = scene.CtrlOverlayButtonCenterForTest(0); - ASSERT_TRUE(before.has_value()); - - scene.SetHover3d({}, base::Action::MODIFIER_NONE); - - TouchMoveAction moveAway; - moveAway.Index = 0; - moveAway.Position = { 40, 40 }; - moveAway.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(moveAway); - - EXPECT_EQ(2, scene.CtrlOverlayVisibleButtonCountForTest()); - auto after = scene.CtrlOverlayButtonCenterForTest(0); - ASSERT_TRUE(after.has_value()); - EXPECT_EQ(before->X, after->X); - EXPECT_EQ(before->Y, after->Y); - - actions::KeyAction ctrlUp; - ctrlUp.KeyActionType = actions::KeyAction::KEY_UP; - ctrlUp.KeyChar = 17; - ctrlUp.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(ctrlUp); -} - -TEST(SceneInteractionRouting, CtrlOverlayStationDepthShowsGlobalAndFractionHandles) { - auto take = MakeLoopTake("overlay-station-depth-take"); - auto station = MakeStation("overlay-station-depth-station"); - station->AddTake(take); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d(HoverPathFor(station), base::Action::MODIFIER_NONE); - - TouchMoveAction moveToAnchor; - moveToAnchor.Index = 0; - moveToAnchor.Position = { 220, 220 }; - moveToAnchor.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(moveToAnchor); - - actions::KeyAction ctrlDown; - ctrlDown.KeyActionType = actions::KeyAction::KEY_DOWN; - ctrlDown.KeyChar = 17; - ctrlDown.Modifiers = base::Action::MODIFIER_CTRL; - scene.OnAction(ctrlDown); - - EXPECT_EQ(2, scene.CtrlOverlayVisibleButtonCountForTest()); - EXPECT_TRUE(scene.CtrlOverlayButtonCenterForTest(0).has_value()); - EXPECT_TRUE(scene.CtrlOverlayButtonCenterForTest(1).has_value()); - EXPECT_FALSE(scene.CtrlOverlayButtonCenterForTest(2).has_value()); - - actions::KeyAction ctrlUp; - ctrlUp.KeyActionType = actions::KeyAction::KEY_UP; - ctrlUp.KeyChar = 17; - ctrlUp.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(ctrlUp); -} - -TEST(SceneInteractionRouting, StationDepthFractionDragAffectsAllLoopTakesInHoveredStation) { - auto firstTake = MakeLoopTake("station-fraction-first"); - auto secondTake = MakeLoopTake("station-fraction-second"); - auto station = MakeStation("station-fraction-station"); - station->AddTake(firstTake); - station->AddTake(secondTake); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - firstTake->SetMidiQuantisation(settings); - secondTake->SetMidiQuantisation(settings); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(station); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d(HoverPathFor(station), base::Action::MODIFIER_NONE); - - ApplyCtrlFractionDrag(scene, { 220, 220 }, { 220, 156 }, 1); - - EXPECT_TRUE(firstTake->MidiQuantisation().Enabled); - EXPECT_TRUE(secondTake->MidiQuantisation().Enabled); - EXPECT_EQ(MidiQuantisationFraction::Quarter, firstTake->MidiQuantisation().Fraction); - EXPECT_EQ(MidiQuantisationFraction::Quarter, secondTake->MidiQuantisation().Fraction); -} - -TEST(SceneInteractionRouting, CtrlOverlayFractionClickUsesLatchedHoverTarget) { - auto hoveredTake = MakeLoopTake("overlay-hovered-take"); - auto driftTake = MakeLoopTake("overlay-drift-take"); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Eighth; - settings.GrainSamps = 1600u; - hoveredTake->SetMidiQuantisation(settings); - driftTake->SetMidiQuantisation(settings); - - auto hoveredStation = MakeStation("overlay-hovered-station"); - hoveredStation->AddTake(hoveredTake); - auto driftStation = MakeStation("overlay-drift-station"); - driftStation->AddTake(driftTake); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(hoveredStation); - scene.AddStationForTest(driftStation); - scene.CommitChanges(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_LOOPTAKE); - scene.SetHover3d(HoverPathFor(hoveredTake), base::Action::MODIFIER_NONE); - - TouchMoveAction moveToAnchor; - moveToAnchor.Index = 0; - moveToAnchor.Position = { 220, 220 }; - moveToAnchor.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(moveToAnchor); - - actions::KeyAction ctrlDown; - ctrlDown.KeyActionType = actions::KeyAction::KEY_DOWN; - ctrlDown.KeyChar = 17; - ctrlDown.Modifiers = base::Action::MODIFIER_CTRL; - scene.OnAction(ctrlDown); - - auto fractionCenter = scene.CtrlOverlayButtonCenterForTest(1); - ASSERT_TRUE(fractionCenter.has_value()); - - scene.SetHover3d(HoverPathFor(driftTake), base::Action::MODIFIER_NONE); - - TouchAction down; - down.State = TouchAction::TOUCH_DOWN; - down.Index = 0; - down.Position = fractionCenter.value(); - down.Modifiers = base::Action::MODIFIER_CTRL; - EXPECT_TRUE(scene.OnAction(down).IsEaten); - - TouchAction up = down; - up.State = TouchAction::TOUCH_UP; - EXPECT_TRUE(scene.OnAction(up).IsEaten); - - actions::KeyAction ctrlUp; - ctrlUp.KeyActionType = actions::KeyAction::KEY_UP; - ctrlUp.KeyChar = 17; - ctrlUp.Modifiers = base::Action::MODIFIER_NONE; - scene.OnAction(ctrlUp); - - EXPECT_TRUE(hoveredTake->MidiQuantisation().Enabled); - EXPECT_FALSE(driftTake->MidiQuantisation().Enabled); -} - -TEST(SceneInteractionRouting, TwoDimensionalLoopTakeTouchDoesNotStartSceneRouting) { - auto touchedTake = MakeLoopTake("take-a"); - auto selectedTake = MakeLoopTake("take-b"); - - MidiQuantisationSettings settings; - settings.Enabled = false; - settings.Fraction = MidiQuantisationFraction::Whole; - settings.GrainSamps = 1600u; - touchedTake->SetMidiQuantisation(settings); - selectedTake->SetMidiQuantisation(settings); - - auto touchedStation = MakeStation("station-a"); - touchedStation->AddTake(touchedTake); - auto selectedStation = MakeStation("station-b"); - selectedStation->AddTake(selectedTake); - - SceneParams sceneParams{ base::DrawableParams(), base::MoveableParams(), base::SizeableParams{ 400, 300 } }; - io::UserConfig userConfig = MakeSceneUserConfig(); - TestScene scene(sceneParams, userConfig); - scene.AddStationForTest(touchedStation); - scene.AddStationForTest(selectedStation); - scene.CommitChanges(); - selectedStation->Select(); - scene.SetSelectDepthForTest(base::SelectDepth::DEPTH_STATION); - scene.SetHover3d(HoverPathFor(touchedTake), base::Action::MODIFIER_NONE); - - ApplyCtrlDrag(scene, { 10, 10 }, { 74, 10 }); - - EXPECT_EQ(0, - touchedTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_EQ(ExpectedPhaseOffsetForDrag({ 10, 10 }, { 74, 10 }), - selectedTake->ResolvedMidiQuantisation().PhaseOffsetSamps); - EXPECT_FALSE(touchedTake->MidiQuantisation().Enabled); - EXPECT_EQ(MidiQuantisationFraction::Whole, touchedTake->MidiQuantisation().Fraction); - EXPECT_FALSE(selectedTake->MidiQuantisation().Enabled); - EXPECT_EQ(MidiQuantisationFraction::Whole, selectedTake->MidiQuantisation().Fraction); -} - TEST(MidiLoopBuildApi, ReplaceRecordedEventsPreservesPlaybackTiming) { MidiLoop loop; diff --git a/test/JammaLib_Tests/src/midi/MidiRouterAutomationKey_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiRouterAutomationKey_Tests.cpp deleted file mode 100644 index 2fe3d160..00000000 --- a/test/JammaLib_Tests/src/midi/MidiRouterAutomationKey_Tests.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include "gtest/gtest.h" - -#include -#include - -#include "actions/KeyAction.h" -#include "midi/MidiRouter.h" - -namespace midi_tests -{ - actions::KeyAction BuildKey(unsigned int keyChar, - int keyActionType, - base::Action::Modifiers modifiers = base::Action::MODIFIER_NONE) - { - actions::KeyAction action; - action.KeyChar = keyChar; - action.KeyActionType = (keyActionType == actions::KeyAction::KEY_DOWN) - ? actions::KeyAction::KEY_DOWN - : actions::KeyAction::KEY_UP; - action.IsSystem = false; - action.Modifiers = modifiers; - return action; - } - - class MidiRouterAutomationKeyTestFixture : public ::testing::Test - { - protected: - void SetUp() override - { - midi::MidiRouter::SetAutomationRecordHeldForTest(false); - } - - void TearDown() override - { - midi::MidiRouter::SetAutomationRecordHeldForTest(false); - } - }; - - TEST_F(MidiRouterAutomationKeyTestFixture, InsertPressAndReleaseControlsAutomationRecord) - { - midi::MidiRouter router; - const std::vector> stations; - const std::vector hoverPath; - const std::shared_ptr hoveredTake; - - auto downResult = router.HandleAutomationKey( - BuildKey(45u, actions::KeyAction::KEY_DOWN), - stations, - hoverPath, - hoveredTake); - - EXPECT_TRUE(downResult.IsEaten); - EXPECT_TRUE(midi::MidiRouter::IsAutomationRecordHeld()); - - auto upResult = router.HandleAutomationKey( - BuildKey(45u, actions::KeyAction::KEY_UP), - stations, - hoverPath, - hoveredTake); - - EXPECT_TRUE(upResult.IsEaten); - EXPECT_FALSE(midi::MidiRouter::IsAutomationRecordHeld()); - } - - TEST_F(MidiRouterAutomationKeyTestFixture, CtrlShiftADoesNotArmAutomationRecord) - { - midi::MidiRouter router; - const std::vector> stations; - const std::vector hoverPath; - const std::shared_ptr hoveredTake; - - auto oldShortcutResult = router.HandleAutomationKey( - BuildKey(65u, - actions::KeyAction::KEY_DOWN, - static_cast(base::Action::MODIFIER_CTRL | base::Action::MODIFIER_SHIFT)), - stations, - hoverPath, - hoveredTake); - - EXPECT_FALSE(oldShortcutResult.IsEaten); - EXPECT_FALSE(midi::MidiRouter::IsAutomationRecordHeld()); - } -} diff --git a/test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp b/test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp deleted file mode 100644 index 40c9839b..00000000 --- a/test/JammaLib_Tests/src/midi/VstEditorAutomationSuppression_Tests.cpp +++ /dev/null @@ -1,97 +0,0 @@ -#include "gtest/gtest.h" - -#include "midi/MidiRouter.h" -#include "vst/IVstPlugin.h" - -using midi::MidiRouter; - -namespace -{ - // Suppression compares plugin pointer identity only; never dereferenced. - const vst::IVstPlugin* FakePlugin(std::uintptr_t id) noexcept - { - return reinterpret_cast(id); - } - - class SuppressionTestFixture : public ::testing::Test - { - protected: - void SetUp() override { MidiRouter::ResetAutomationSuppressionForTest(); } - void TearDown() override { MidiRouter::ResetAutomationSuppressionForTest(); } - }; -} - -TEST_F(SuppressionTestFixture, UnknownParameterIsNotSuppressed) -{ - auto* plugin = FakePlugin(0x100u); - EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 0u, 0u)); -} - -TEST_F(SuppressionTestFixture, FreshEntrySuppressesUntilExpiry) -{ - auto* plugin = FakePlugin(0x110u); - MidiRouter::RefreshAutomationSuppression(plugin, 4u, /*now*/ 1000u, /*expiry*/ 2000u); - - EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 4u, 1500u)); - EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 4u, 1999u)); -} - -TEST_F(SuppressionTestFixture, ExpiredEntryNoLongerSuppresses) -{ - auto* plugin = FakePlugin(0x120u); - MidiRouter::RefreshAutomationSuppression(plugin, 1u, /*now*/ 1000u, /*expiry*/ 2000u); - - EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 1u, 2000u)); - EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 1u, 2500u)); -} - -TEST_F(SuppressionTestFixture, DifferentParameterIsIndependent) -{ - auto* plugin = FakePlugin(0x130u); - MidiRouter::RefreshAutomationSuppression(plugin, 2u, /*now*/ 0u, /*expiry*/ 5000u); - - EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 2u, 1000u)); - EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 3u, 1000u)); -} - -TEST_F(SuppressionTestFixture, DifferentPluginIsIndependent) -{ - auto* pluginA = FakePlugin(0x140u); - auto* pluginB = FakePlugin(0x141u); - MidiRouter::RefreshAutomationSuppression(pluginA, 0u, /*now*/ 0u, /*expiry*/ 5000u); - - EXPECT_TRUE(MidiRouter::IsParameterSuppressed(pluginA, 0u, 1000u)); - EXPECT_FALSE(MidiRouter::IsParameterSuppressed(pluginB, 0u, 1000u)); -} - -TEST_F(SuppressionTestFixture, RefreshExtendsExistingEntry) -{ - auto* plugin = FakePlugin(0x150u); - MidiRouter::RefreshAutomationSuppression(plugin, 6u, /*now*/ 0u, /*expiry*/ 1000u); - EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 6u, 1000u)); - - // Extending the same (plugin, param) pushes the deadline out without - // consuming a second slot. - MidiRouter::RefreshAutomationSuppression(plugin, 6u, /*now*/ 900u, /*expiry*/ 3000u); - EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 6u, 2000u)); -} - -TEST_F(SuppressionTestFixture, ExpiredSlotIsReclaimedForNewParameter) -{ - // First parameter expires... - auto* plugin = FakePlugin(0x160u); - MidiRouter::RefreshAutomationSuppression(plugin, 0u, /*now*/ 0u, /*expiry*/ 1000u); - - // ...then a new parameter is registered after that deadline; it should reuse - // the freed slot and suppress correctly. - MidiRouter::RefreshAutomationSuppression(plugin, 1u, /*now*/ 2000u, /*expiry*/ 4000u); - - EXPECT_FALSE(MidiRouter::IsParameterSuppressed(plugin, 0u, 2000u)); - EXPECT_TRUE(MidiRouter::IsParameterSuppressed(plugin, 1u, 3000u)); -} - -TEST_F(SuppressionTestFixture, NullPluginIsNeverSuppressed) -{ - MidiRouter::RefreshAutomationSuppression(nullptr, 0u, 0u, 5000u); - EXPECT_FALSE(MidiRouter::IsParameterSuppressed(nullptr, 0u, 1000u)); -} diff --git a/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp b/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp index 28c6339d..6dc1fbfb 100644 --- a/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp +++ b/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp @@ -189,20 +189,4 @@ TEST(Vst2PluginHostCallback, ReportsOutboundMidiCapability) EXPECT_FALSE(vst::Vst2Plugin::SupportsHostCanDo("offline")); } -TEST(Vst2PluginHostCallback, AudioMasterAutomatePublishesTouchWithoutEchoingParameter) -{ - vst::Vst2Plugin plugin; - AEffect effect{}; - effect.user = &plugin; - effect.setParameter = HostEchoSetParameter; - - const auto seqBefore = vst::_lastTouchedParam.Sequence.load(std::memory_order_relaxed); - vst::Vst2Plugin::HostCallbackForTest(&effect, audioMasterAutomate, 7, 0, nullptr, 0.42f); - - EXPECT_EQ(&plugin, vst::_lastTouchedParam.Plugin.load(std::memory_order_relaxed)); - EXPECT_EQ(7u, vst::_lastTouchedParam.ParameterIndex.load(std::memory_order_relaxed)); - EXPECT_FLOAT_EQ(0.42f, vst::_lastTouchedParam.Value.load(std::memory_order_relaxed)); - EXPECT_EQ(seqBefore + 1u, vst::_lastTouchedParam.Sequence.load(std::memory_order_relaxed)); -} - #endif // JAMMA_VST2_ENABLED From 7c24155c1b960fe8a69251c135ea89e6681e06d7 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 11:03:29 +0100 Subject: [PATCH 22/27] Move erroneous comment to correct line --- JammaLib/src/vst/IVstPlugin.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/JammaLib/src/vst/IVstPlugin.h b/JammaLib/src/vst/IVstPlugin.h index 890e14d2..cf9276a6 100644 --- a/JammaLib/src/vst/IVstPlugin.h +++ b/JammaLib/src/vst/IVstPlugin.h @@ -154,7 +154,8 @@ namespace vst // Factory: creates the correct plugin type based on file extension. // Extension ".dll" -> Vst2Plugin (VST2) // Any other extension (e.g. ".vst3") -> Vst3Plugin (VST3) - std::shared_ptr MakePluginForPath(const std::wstring& path); // Queue a plugin for destruction on the UI thread. + std::shared_ptr MakePluginForPath(const std::wstring& path); + // Queue a plugin for destruction on the UI thread. // VST3 plugins must be destroyed on the UI (message-pump) thread to avoid // crashing the host. Vst2Plugin may also be queued here for uniformity. // Safe to call from any thread. From f9e746a22b205f76ced56af4fa7ddf5d0a976eb4 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 11:24:03 +0100 Subject: [PATCH 23/27] Clean up duplicate inline helper and move out of header --- JammaLib/src/engine/Scene.cpp | 11 +++++++++++ JammaLib/src/engine/Scene.h | 12 +++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/JammaLib/src/engine/Scene.cpp b/JammaLib/src/engine/Scene.cpp index 4fb01ccf..9717328f 100644 --- a/JammaLib/src/engine/Scene.cpp +++ b/JammaLib/src/engine/Scene.cpp @@ -1167,6 +1167,17 @@ void Scene::_SetQuantisation(unsigned int quantiseSamps, Timer::QuantisationType _quantisation.SetMidiGrain(quantiseSamps, "scene quantisation set", _stations); } +void Scene::_SetMidiQuantisationGrain(unsigned int grainSamps, const char* source) +{ + _quantisation.SetMidiGrain(grainSamps, source, _stations); +} + +void Scene::_UpdateRemoteStationsFromSnapshot(const NinjamRemoteSnapshot& snapshot) +{ + if (_networkService->UpdateRemoteStationsFromSnapshot(snapshot, _stations)) + _PublishAudioStations(); +} + bool Scene::_IsMidiPhaseDragModifier(base::Action::Modifiers modifiers) const noexcept { return (Action::MODIFIER_CTRL & modifiers); diff --git a/JammaLib/src/engine/Scene.h b/JammaLib/src/engine/Scene.h index a206f169..d9ccbc5f 100644 --- a/JammaLib/src/engine/Scene.h +++ b/JammaLib/src/engine/Scene.h @@ -266,10 +266,8 @@ namespace engine void _AddStation(std::shared_ptr station); void _HandleReclockArm(); actions::ActionResult _HandleUndo(); - void _SetQuantisation(unsigned int quantiseSamps, utils::Timer::QuantisationType quantisation); void _SetMidiQuantisationGrain(unsigned int grainSamps, const char* source) - { - _quantisation.SetMidiGrain(grainSamps, source, _stations); - } + void _SetQuantisation(unsigned int quantiseSamps, utils::Timer::QuantisationType quantisation); + void _SetMidiQuantisationGrain(unsigned int grainSamps, const char* source); void _JobLoop(); void _PumpMidi(); void _RegisterMidiTriggerRoute(const std::string& deviceName, std::shared_ptr trigger); @@ -277,11 +275,7 @@ namespace engine void _PublishAudioStations(); std::shared_ptr _ChildFromPath(std::vector path); void _UpdateSelectDepth(unsigned int depth); - void _UpdateRemoteStationsFromSnapshot(const ninjam::NinjamRemoteSnapshot& snapshot) - { - if (_networkService->UpdateRemoteStationsFromSnapshot(snapshot, _stations)) - _PublishAudioStations(); - } + void _UpdateRemoteStationsFromSnapshot(const ninjam::NinjamRemoteSnapshot& snapshot); timing::QuantisationPolicy _QuantisationPolicy() const; unsigned int _CurrentSampleRate() const; std::uint64_t _EstimatedAudioSampleAt(Time actionTime) const; From bddadc748040747406cb9334de0791f9fc0a284f Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 11:40:18 +0100 Subject: [PATCH 24/27] Improve window location/size calcs for multi monitor setups --- JammaLib/src/graphics/Window.cpp | 71 +++++++++++++++++++++++++++++++- JammaLib/src/graphics/Window.h | 3 ++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/JammaLib/src/graphics/Window.cpp b/JammaLib/src/graphics/Window.cpp index d1044d58..ed88425f 100644 --- a/JammaLib/src/graphics/Window.cpp +++ b/JammaLib/src/graphics/Window.cpp @@ -208,7 +208,20 @@ int Window::Create(HINSTANCE hInstance, int nCmdShow) } auto size = (WINDOWED == _config.State) ? AdjustSize(_config.Size, _style) : _config.Size; - auto pos = _config.Position;// (WINDOWED == _config.State) ? Center(size) : _config.Position; + auto pos = _config.Position; + if (WINDOWED == _config.State) + { + RECT desiredRect = { + pos.X, + pos.Y, + pos.X + static_cast(size.Width), + pos.Y + static_cast(size.Height) + }; + + RECT workArea{}; + if (GetMonitorWorkAreaForRect(desiredRect, workArea)) + ClampToWorkArea(pos, size, workArea); + } // Create a new window and context _wnd = CreateWindowEx( @@ -586,6 +599,43 @@ Size2d Window::AdjustSize(Size2d size, DWORD style) h < 1 ? 1 : (unsigned int)h }; } +bool Window::GetMonitorWorkArea(HMONITOR monitor, RECT& workArea) noexcept +{ + if (!monitor) + return false; + + MONITORINFO monitorInfo{}; + monitorInfo.cbSize = sizeof(monitorInfo); + if (!GetMonitorInfo(monitor, &monitorInfo)) + return false; + + workArea = monitorInfo.rcWork; + return true; +} + +bool Window::GetMonitorWorkAreaForRect(const RECT& rect, RECT& workArea) noexcept +{ + const auto monitor = MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST); + return GetMonitorWorkArea(monitor, workArea); +} + +void Window::ClampToWorkArea(Position2d& position, Size2d& size, const RECT& workArea) noexcept +{ + const auto workWidth = static_cast(std::max(1, workArea.right - workArea.left)); + const auto workHeight = static_cast(std::max(1, workArea.bottom - workArea.top)); + + if (size.Width > workWidth) + size.Width = workWidth; + if (size.Height > workHeight) + size.Height = workHeight; + + const auto maxX = std::max(workArea.left, workArea.right - static_cast(size.Width)); + const auto maxY = std::max(workArea.top, workArea.bottom - static_cast(size.Height)); + + position.X = std::clamp(position.X, static_cast(workArea.left), maxX); + position.Y = std::clamp(position.Y, static_cast(workArea.top), maxY); +} + Position2d Window::Center(Size2d size) { RECT primaryDisplaySize; @@ -702,6 +752,25 @@ LRESULT CALLBACK Window::WindowProcedure(HWND hWindow, UINT message, WPARAM wPar MinMaxInfo->ptMinTrackSize.x = (long)min.Width; MinMaxInfo->ptMinTrackSize.y = (long)min.Height; + + RECT workArea{}; + const auto monitor = MonitorFromWindow(hWindow, MONITOR_DEFAULTTONEAREST); + if (monitor && GetMonitorWorkArea(monitor, workArea)) + { + MONITORINFO monitorInfo{}; + monitorInfo.cbSize = sizeof(monitorInfo); + if (GetMonitorInfo(monitor, &monitorInfo)) + { + const auto workWidth = workArea.right - workArea.left; + const auto workHeight = workArea.bottom - workArea.top; + MinMaxInfo->ptMaxPosition.x = workArea.left - monitorInfo.rcMonitor.left; + MinMaxInfo->ptMaxPosition.y = workArea.top - monitorInfo.rcMonitor.top; + MinMaxInfo->ptMaxSize.x = workWidth; + MinMaxInfo->ptMaxSize.y = workHeight; + MinMaxInfo->ptMaxTrackSize.x = workWidth; + MinMaxInfo->ptMaxTrackSize.y = workHeight; + } + } return 0; } case WM_ENTERSIZEMOVE: diff --git a/JammaLib/src/graphics/Window.h b/JammaLib/src/graphics/Window.h index 6aa327a0..753803f6 100644 --- a/JammaLib/src/graphics/Window.h +++ b/JammaLib/src/graphics/Window.h @@ -86,6 +86,9 @@ namespace graphics static utils::Size2d AdjustSize(utils::Size2d size, DWORD style); static utils::Position2d Center(utils::Size2d size); + static bool GetMonitorWorkArea(HMONITOR monitor, RECT& workArea) noexcept; + static bool GetMonitorWorkAreaForRect(const RECT& rect, RECT& workArea) noexcept; + static void ClampToWorkArea(utils::Position2d& position, utils::Size2d& size, const RECT& workArea) noexcept; static ATOM Register(HINSTANCE hInstance); static LRESULT CALLBACK WindowProcedure(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept; From 949e5fbfe6d00ccd79be0980a94f578e2fe03629 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 11:40:26 +0100 Subject: [PATCH 25/27] Fix failing test --- test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp index f37f026c..237a2025 100644 --- a/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp +++ b/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp @@ -470,7 +470,9 @@ TEST(LoopTakeMidiVisualization, PlayFinalizesMidiModelSpans) { auto take = MakeLoopTake(); take->Record({}, "station", { 3u }); - auto midiModel = std::dynamic_pointer_cast(take->TryGetChild(1u)); + ASSERT_EQ(1u, take->GetMidiLoops().size()); + auto midiLoop = take->GetMidiLoops()[0]; + auto midiModel = midiLoop->Model(); ASSERT_NE(nullptr, midiModel); EXPECT_TRUE(take->RecordMidiEvent(MidiEvent::MakeNoteOn(0u, 3, 60, 100), 0u)); @@ -478,6 +480,7 @@ TEST(LoopTakeMidiVisualization, PlayFinalizesMidiModelSpans) EXPECT_TRUE(take->RecordMidiEvent(MidiEvent::MakeNoteOff(0u, 3, 60), 0u)); take->Play(0ul, 960ul, 0u); + EXPECT_TRUE(midiLoop->UpdateModelFromEvents(960u, true)); EXPECT_EQ(1u, midiModel->NoteInstanceCount()); } From 176cc2e8ed4bae4ba12d177406b7d1cba3775db7 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 11:59:51 +0100 Subject: [PATCH 26/27] Merge conflict resolution --- JammaLib/src/gui/SceneSelector.h | 1 + 1 file changed, 1 insertion(+) diff --git a/JammaLib/src/gui/SceneSelector.h b/JammaLib/src/gui/SceneSelector.h index 1c8df825..1e4abc6c 100644 --- a/JammaLib/src/gui/SceneSelector.h +++ b/JammaLib/src/gui/SceneSelector.h @@ -41,6 +41,7 @@ namespace gui base::SelectDepth CurrentSelectDepth() const; void SetSelectDepth(base::SelectDepth level); std::vector CurrentHover() const; + std::vector PaintedPathForTest() const; bool UpdateCurrentHover(std::vector path, base::Action::Modifiers modifiers, bool isSelected, From 6a269ca5240b3f1a582f2fd63d427b147f707bd2 Mon Sep 17 00:00:00 2001 From: malisimat Date: Sun, 21 Jun 2026 12:05:03 +0100 Subject: [PATCH 27/27] Window resizes properly and avoids stretching --- JammaLib/src/graphics/GlDrawContext.cpp | 1 + JammaLib/src/graphics/Window.cpp | 36 ++++++++++++++++++++++--- JammaLib/src/graphics/Window.h | 2 ++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/JammaLib/src/graphics/GlDrawContext.cpp b/JammaLib/src/graphics/GlDrawContext.cpp index cfe018c3..b380cfd9 100644 --- a/JammaLib/src/graphics/GlDrawContext.cpp +++ b/JammaLib/src/graphics/GlDrawContext.cpp @@ -101,6 +101,7 @@ void GlDrawContext::Initialise() void GlDrawContext::Bind() { glBindFramebuffer(GL_FRAMEBUFFER, _frameBuffer); + glViewport(0, 0, static_cast(_size.Width), static_cast(_size.Height)); _scissorStack.clear(); glDisable(GL_SCISSOR_TEST); } diff --git a/JammaLib/src/graphics/Window.cpp b/JammaLib/src/graphics/Window.cpp index ed88425f..be0c2574 100644 --- a/JammaLib/src/graphics/Window.cpp +++ b/JammaLib/src/graphics/Window.cpp @@ -35,6 +35,7 @@ Window::Window(Scene& scene, _released(false), _buttonsDown(0), _lastHoverObjectId(0), + _pendingResize(std::nullopt), _modifiers(Action::MODIFIER_NONE), _highlightPass(ImageFullscreenParams(base::DrawableParams{""}, "blur")) { @@ -377,11 +378,38 @@ void Window::SetTrackingMouse(bool tracking) void Window::Resize(Size2d size) { + if (size.Width < 1) + size.Width = 1; + if (size.Height < 1) + size.Height = 1; + _config.Size = size; + _scene.SetSize(size); + _lastHoverObjectId = 0; + _pendingResize = size; +} + +void Window::ApplyPendingResize() +{ + if (!_pendingResize.has_value()) + return; + + if (!GlDeleteQueue::IsRenderThread()) + return; + + if (!_pickContext.has_value() || !_textureContext.has_value() || !_drawContext.has_value()) + return; + + auto size = _pendingResize.value(); + _pickContext.emplace(size, base::DrawContext::ContextTarget::PICKING); + _textureContext.emplace(size, base::DrawContext::ContextTarget::TEXTURE); + _drawContext.emplace(size, base::DrawContext::ContextTarget::SCREEN); _pickContext->Initialise(); _textureContext->Initialise(); _drawContext->Initialise(); + + _pendingResize.reset(); } void Window::SetWindowState(WindowState state) @@ -397,6 +425,7 @@ Size2d Window::GetSize() void Window::Render() { GlDeleteQueue::FlushPendingDeletes(); + ApplyPendingResize(); _scene.CommitChanges(); _scene.InitResources(_resourceLib, false); @@ -474,9 +503,8 @@ ActionResult Window::OnAction(WindowAction winAction) switch (winAction.WindowEventType) { case WindowAction::SIZE: - _config.Size = winAction.Size; + Resize(winAction.Size); isEaten = true; - //AdjustSize(); break; case WindowAction::SIZE_MINIMISE: SetWindowState(Window::MINIMISED); @@ -484,9 +512,9 @@ ActionResult Window::OnAction(WindowAction winAction) break; case WindowAction::SIZE_MAXIMISE: SetWindowState(Window::MAXIMISED); - _config.Size = winAction.Size; + Resize(winAction.Size); isEaten = true; - //AdjustSize(); + break; case WindowAction::DESTROY: break; } diff --git a/JammaLib/src/graphics/Window.h b/JammaLib/src/graphics/Window.h index 753803f6..8ebd5ecd 100644 --- a/JammaLib/src/graphics/Window.h +++ b/JammaLib/src/graphics/Window.h @@ -95,6 +95,7 @@ namespace graphics private: void LoadResources(); void InitScene(); + void ApplyPendingResize(); void ReleaseGlResources(); static void InitStyle(WNDCLASSEX& wcex) noexcept; @@ -112,6 +113,7 @@ namespace graphics bool _released; unsigned int _buttonsDown; unsigned int _lastHoverObjectId; + std::optional _pendingResize; std::optional _drawContext; std::optional _pickContext;