diff --git a/Jamma/Jamma.vcxproj b/Jamma/Jamma.vcxproj index 33301fc3..87a1f097 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)" + @@ -249,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 30443d88..30ee599a 100644 --- a/Jamma/resources/ResourceList.txt +++ b/Jamma/resources/ResourceList.txt @@ -9,6 +9,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..4eb9f046 --- /dev/null +++ b/Jamma/resources/shaders/automation.vert @@ -0,0 +1,81 @@ +#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. +const int MaxAutoPoints = 512; +uniform vec2 AutoPoints[MaxAutoPoints]; +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 < MaxAutoPoints - 1; ++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/Jamma/src/Main.cpp b/Jamma/src/Main.cpp index bdd23d83..eb3502a7 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); @@ -358,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; @@ -380,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/JammaLib.vcxproj b/JammaLib/JammaLib.vcxproj index 9fb42fd6..5ff28c2f 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/JammaLib/src/engine/LoopTake.cpp b/JammaLib/src/engine/LoopTake.cpp index d5ad7cdd..4391873c 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); } } @@ -2638,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/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/Scene.cpp b/JammaLib/src/engine/Scene.cpp index 083af8e8..7d78dadb 100644 --- a/JammaLib/src/engine/Scene.cpp +++ b/JammaLib/src/engine/Scene.cpp @@ -615,6 +615,18 @@ ActionResult Scene::OnAction(KeyAction action) _networkService->GetController()); } + // 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); + auto automationRes = _inputSubsystem->HandleAutomationKey(action, + _stations, + _selector->CurrentHover(), + hoveredTake); + if (automationRes.IsEaten) + return automationRes; + } + bool checkReset = false; auto result = ActionResult::NoAction(); @@ -943,12 +955,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(); } @@ -1207,16 +1235,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(); @@ -1249,6 +1267,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 16e15aee..2bf00f27 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" @@ -191,9 +192,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 AddChild(std::shared_ptr child); void SetHover3d(std::vector path, base::Action::Modifiers modifiers); @@ -203,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() @@ -269,10 +270,7 @@ namespace engine 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 _SetMidiQuantisationGrain(unsigned int grainSamps, const char* source); void _JobLoop(); void _PumpMidi(); void _RegisterMidiTriggerRoute(const std::string& deviceName, std::shared_ptr trigger); @@ -280,11 +278,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; diff --git a/JammaLib/src/engine/Station.cpp b/JammaLib/src/engine/Station.cpp index 09271afa..0c82dafc 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, sampsToRead); + if (channelCount == 0u) { _masterMixer->UpdateVu(0.0f, sampsToRead); @@ -450,6 +457,156 @@ 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.loopPhaseAnchor = midiLoop->LoopPhaseAnchor(); + entry.loopLengthSamps = midiLoop->LoopLengthSamps(); + ++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(static_cast(buf), std::memory_order_release); + _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, + std::uint32_t numSamps) noexcept +{ + 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) + { + const auto& entry = dispatches[i]; + if (!entry.plugin || !entry.loop) + continue; + + // 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, dispatchSample)) + continue; + + const double frac = (entry.loopLengthSamps > 0u) + ? std::fmod( + static_cast(dispatchSample - entry.loopPhaseAnchor), + static_cast(entry.loopLengthSamps)) + / static_cast(entry.loopLengthSamps) + : 0.0; + + auto& state = _automationPlaybackState[i]; + const float val = entry.loop->GetAutomationValueAtCursor(entry.laneIdx, frac, state.cursorIdx); + + if (std::abs(val - state.lastValue) > AutomationEpsilon) + { + entry.plugin->SetParameter(entry.paramIdx, val); + state.lastValue = val; + } + } +} + void Station::EndMultiPlay(unsigned int numSamps) { auto state = _AudioStateSnapshot(); @@ -1692,6 +1849,7 @@ void Station::_DitchLoopTake(std::shared_ptr& take) noexcept } } take->Ditch(); + RebuildAutomationDispatch(); } void Station::LoadVstPlugin(std::wstring path, diff --git a/JammaLib/src/engine/Station.h b/JammaLib/src/engine/Station.h index f699171c..ce456f34 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(); + + // 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; + // Called on the job thread to actually perform the load / unload. virtual actions::ActionResult OnAction(actions::JobAction action) override; @@ -251,6 +265,43 @@ 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. + // 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 + 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::uint32_t loopPhaseAnchor = 0u; // global sample mapping to loop position 0 + }; + // 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 + + // 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, + 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. + static std::shared_ptr _LastRecordedMidiLoop(const std::shared_ptr& take); + bool _flipTakeBuffer; bool _flipAudioBuffer; std::string _name; @@ -274,6 +325,21 @@ 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. + // 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. // Published atomically for lock-free audio-thread reads. 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/MidiModel.cpp b/JammaLib/src/graphics/MidiModel.cpp index 3f6f44ce..ce5073af 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.64f; + 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.5f * (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/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/graphics/Window.cpp b/JammaLib/src/graphics/Window.cpp index d1044d58..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")) { @@ -208,7 +209,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( @@ -364,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) @@ -384,6 +425,7 @@ Size2d Window::GetSize() void Window::Render() { GlDeleteQueue::FlushPendingDeletes(); + ApplyPendingResize(); _scene.CommitChanges(); _scene.InitResources(_resourceLib, false); @@ -461,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); @@ -471,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; } @@ -586,6 +627,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 +780,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..8ebd5ecd 100644 --- a/JammaLib/src/graphics/Window.h +++ b/JammaLib/src/graphics/Window.h @@ -86,12 +86,16 @@ 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; private: void LoadResources(); void InitScene(); + void ApplyPendingResize(); void ReleaseGlResources(); static void InitStyle(WNDCLASSEX& wcex) noexcept; @@ -109,6 +113,7 @@ namespace graphics bool _released; unsigned int _buttonsDown; unsigned int _lastHoverObjectId; + std::optional _pendingResize; std::optional _drawContext; std::optional _pickContext; diff --git a/JammaLib/src/io/IoInputSubsystem.cpp b/JammaLib/src/io/IoInputSubsystem.cpp index 85c18cfd..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, @@ -57,6 +126,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..6a16ad85 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, @@ -36,11 +40,19 @@ namespace io const audio::AudioStreamParams& streamParams, std::mutex& audioMutex); - void RegisterMidiTriggerRoute(const std::string& deviceName, std::shared_ptr trigger); + actions::ActionResult HandleAutomationKey(const actions::KeyAction& action, + const std::vector>& stations, + const std::vector& hoverPath, + const std::shared_ptr& hoveredTake); - midi::MidiRouter& GetMidiRouterForTest() { return _midiRouter; } + void RegisterMidiTriggerRoute(const std::string& deviceName, std::shared_ptr trigger); 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/MidiLoop.cpp b/JammaLib/src/midi/MidiLoop.cpp index 712bf18c..7310efae 100644 --- a/JammaLib/src/midi/MidiLoop.cpp +++ b/JammaLib/src/midi/MidiLoop.cpp @@ -10,15 +10,159 @@ 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; + if (0u == loopLengthSamps || sampleRate <= 0.0f) + return 0.0f; + 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; +} + +bool MidiLoop::FracIsAheadWithin(float candidateFrac, + float startFrac, + float windowFrac) noexcept +{ + if (windowFrac <= 0.0f) return false; + if (windowFrac >= 1.0f) return true; + + float ahead = candidateFrac - startFrac; + if (ahead < 0.0f) ahead += 1.0f; + return ahead < windowFrac; +} + +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; +} + +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); + + // 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) + { + if (!FracIsAheadWithin(points[i].first, frac, protectWindowFrac)) + { + evictAt = i; + break; + } + } + + // 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 = insertAt; i > 0u; --i) + { + const auto idx = i - 1u; + if (!FracIsAheadWithin(points[idx].first, frac, protectWindowFrac)) + { + evictAt = idx; + break; + } + } + } + + // 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; + + // Compact the array by removing the evicted entry. + for (std::size_t i = evictAt + 1u; i < count; ++i) + points[i - 1u] = points[i]; + --count; + + // 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 : _eventCount(0), + _sampleRate(static_cast(constants::DefaultSampleRate)), _loopLengthSamps(0), + _loopPhaseAnchor(0), _dropped(0), _revision(0), _modelRevision(0), @@ -106,11 +250,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; @@ -145,6 +290,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; } @@ -357,3 +504,255 @@ 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 = ClampAutomationFrac(frac); + + 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); + + InsertOrUpdateAutomationPoint(points, count, _sampleRate, _loopLengthSamps, fracF, value); + + 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) + { + count = 0u; + } + else + { + // 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) + { + if (FracWithinOverwriteWindow(points[i].first, startFrac, endFrac, wraps)) + continue; + + points[keptCount++] = points[i]; + } + count = keptCount; + } + + InsertOrUpdateAutomationPoint(points, count, _sampleRate, _loopLengthSamps, startFrac, value); + InsertOrUpdateAutomationPoint(points, count, _sampleRate, _loopLengthSamps, endFrac, value); + + lane.Revision.store(gen + 2u, std::memory_order_release); +} + +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 fracF = static_cast(frac); + + // 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 +{ + 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.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); +} + +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 +{ + 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 af21b279..fde25a41 100644 --- a/JammaLib/src/midi/MidiLoop.h +++ b/JammaLib/src/midi/MidiLoop.h @@ -6,12 +6,20 @@ #include #include #include +#include +#include #include #include "../graphics/MidiModel.h" +#include "../include/Constants.h" #include "MidiEvent.h" #include "MidiQuantisation.h" +namespace vst +{ + class IVstPlugin; +} + namespace midi { using namespace graphics; @@ -40,6 +48,71 @@ 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); + } + + // 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; + } + 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 + { + // 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) + 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. 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 }; + }; + // 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. @@ -85,7 +158,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). @@ -101,6 +176,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. @@ -113,6 +192,70 @@ 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; + + // 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). + // 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; + + // 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 + // 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 @@ -121,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); @@ -135,11 +283,37 @@ 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; std::size_t _eventCount; + float _sampleRate; std::uint32_t _loopLengthSamps; + std::uint32_t _loopPhaseAnchor; std::uint64_t _dropped; std::uint64_t _revision; std::uint64_t _modelRevision; @@ -148,5 +322,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..4e1dffb7 100644 --- a/JammaLib/src/midi/MidiRouter.cpp +++ b/JammaLib/src/midi/MidiRouter.cpp @@ -1,15 +1,276 @@ #include "MidiRouter.h" #include +#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 +{ + std::atomic MidiRouter::_automationRecordHeld{ false }; + std::array MidiRouter::_automationSuppressions{}; + std::atomic MidiRouter::_automationSuppressionCount{ 0u }; +} + +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; + + constexpr unsigned int InsertKey = 45u; + if (InsertKey == action.KeyChar) + { + if (isDown && !_automationRecordKeyHeld) + { + _automationRecordKeyHeld = true; + _ResetEditorTouchStates(); + _automationRecordHeld.store(true, std::memory_order_release); + std::cout << ">> Automation record armed (Insert) <<" << 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::_ResetEditorTouchStates() noexcept +{ + for (auto& state : _editorTouchStates) + state.Active = false; +} + +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::InitMidi(const io::UserConfig& cfg, const base::LoggingConfig& loggingConfig, std::atomic& audioSampleCounter, @@ -217,84 +478,157 @@ 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) +void MidiRouter::_ConsumeEditorAutomation(const std::vector>& stations, + std::uint64_t globalSampleNow, + const audio::AudioStreamParams& audioParams) noexcept { - if (!trigger) + // Always advance the sequence cursor so that touches made before Insert + // was pressed are not replayed as soon as record mode is armed. + const auto seq = vst::_lastTouchedParam.Sequence.load(std::memory_order_acquire); + const bool newTouch = (seq != _lastEditorAutomationSeq); + if (newTouch) + _lastEditorAutomationSeq = seq; + + if (!_automationRecordHeld.load(std::memory_order_acquire)) 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); + 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; + + // ----------------------------------------------------------------------- + // 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) + { + auto* plugin = vst::_lastTouchedParam.Plugin.load(std::memory_order_acquire); + if (plugin) + { + const auto paramIdx = vst::_lastTouchedParam.ParameterIndex.load(std::memory_order_acquire); + const auto value = vst::_lastTouchedParam.Value.load(std::memory_order_acquire); - _midiInputs.store(updatedInputs, std::memory_order_release); -} + std::shared_ptr targetLoop; + for (const auto& station : stations) + { + if (!station || station->IsRemote()) + continue; + if (auto loop = station->ResolveEditorAutomationLoop(plugin)) + { + targetLoop = loop; + break; + } + } -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; + 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; + + 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 + // Insert 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"; + } + + touchState->Loop = targetLoop; + touchState->LaneIdx = laneIdx; + touchState->LastTouchSample = nowSample; + + 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); + } + } + } + } + } + } - for (const auto& input : *midiInputs) + // ----------------------------------------------------------------------- + // 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 (!input || (input->DeviceSlot != deviceSlot)) + if (!state.Active) 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; + if (static_cast(nowSample - state.LastTouchSample) + > static_cast(cooldownSamples)) + { + state.Active = false; + continue; + } - for (const auto& input : *midiInputs) - { - if (input && (input->DeviceSlot == deviceSlot)) - return true; + auto loop = state.Loop.lock(); + if (!loop) + { + state.Active = false; + continue; + } } - - 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); } MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector>& stations, @@ -306,8 +640,10 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector(ingress.data2) / 127.0f; + for (const auto& station : stations) + { + if (!station || station->IsRemote()) + continue; + + for (const auto& take : station->GetLoopTakes()) + { + if (!take) + continue; + + for (const auto& loop : take->GetMidiLoops()) + { + if (!loop) + continue; + + const auto loopLen = loop->LoopLengthSamps(); + if (loopLen == 0u) + continue; + + 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); + 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; @@ -353,6 +742,8 @@ MidiRouter::TriggerDispatchSummary MidiRouter::PumpMidi(const std::vector #include +#include #include #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" @@ -19,14 +24,24 @@ namespace io struct UserConfig; } +namespace vst +{ + class IVstPlugin; +} + namespace engine { + class LoopTake; class Station; class Trigger; } namespace midi { + class MidiLoop; + + static constexpr std::uint8_t LearnNothingCaptured = 0xffu; + class MidiRouter { public: @@ -50,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, @@ -74,6 +75,38 @@ 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; + + // --- 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; + private: static constexpr std::uint8_t UnresolvedMidiDeviceSlot = 0xffu; @@ -97,8 +130,21 @@ 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(); + // 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 _ResetEditorTouchStates() noexcept; + std::atomic>>> _midiInputs; std::vector _midiTriggerRoutes; std::atomic>> _midiTriggerRoutesSnapshot; @@ -106,5 +152,50 @@ 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; + + // Highest editor-origin sequence already consumed by _ConsumeEditorAutomation. + // Touched only on the (single) MIDI pump thread. + std::uint64_t _lastEditorAutomationSeq = 0u; + + // 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: + // • _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. + // • 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; + std::weak_ptr Loop; + std::size_t LaneIdx = 0u; + }; + 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 + // 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/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/IVstPlugin.h b/JammaLib/src/vst/IVstPlugin.h index 75b4fd75..cf9276a6 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,11 +121,40 @@ 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), 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/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; + + // 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) 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. diff --git a/JammaLib/src/vst/Vst.cpp b/JammaLib/src/vst/Vst.cpp index 61398c39..aaf6a6ea 100644 --- a/JammaLib/src/vst/Vst.cpp +++ b/JammaLib/src/vst/Vst.cpp @@ -1,2 +1,20 @@ // 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; + + 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 b69213c9..84979b70 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,16 @@ 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 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) + { + PublishLastTouchedParameter(self, static_cast(index), opt); + } 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..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,12 +1270,89 @@ 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; #endif } +void Vst3Plugin::SetParameter(unsigned int index, float value) noexcept +{ +#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, std::uint32_t numSamples) noexcept { diff --git a/JammaLib/src/vst/Vst3Plugin.h b/JammaLib/src/vst/Vst3Plugin.h index 710bbde3..aaa94db4 100644 --- a/JammaLib/src/vst/Vst3Plugin.h +++ b/JammaLib/src/vst/Vst3Plugin.h @@ -79,10 +79,20 @@ 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 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; + void BeginMidiBlock(std::uint32_t blockStartSample, 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. 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/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 diff --git a/test/JammaLib_Tests/JammaLib_Tests.vcxproj b/test/JammaLib_Tests/JammaLib_Tests.vcxproj index c64c9ca5..9205cba2 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) @@ -190,6 +190,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 72086f74..6eb1f09e 100644 --- a/test/JammaLib_Tests/JammaLib_Tests.vcxproj.filters +++ b/test/JammaLib_Tests/JammaLib_Tests.vcxproj.filters @@ -107,6 +107,9 @@ src\midi + + src\midi + src\midi diff --git a/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp b/test/JammaLib_Tests/src/engine/StationMidiInstrument_Tests.cpp index 6df0b267..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 @@ -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,4 @@ 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 +} 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/midi/MidiAutomationLaneResolution_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp new file mode 100644 index 00000000..42a53745 --- /dev/null +++ b/test/JammaLib_Tests/src/midi/MidiAutomationLaneResolution_Tests.cpp @@ -0,0 +1,389 @@ +#include + +#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); +} + +// ============================================================================ +// 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(MidiAutomationPhaseAnchor, 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 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 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), + 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); +} + +// 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); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 0u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 0u)); + + // 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()); + + // 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); +} + +// 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); + + const auto lane = loop.ResolveAutomationLaneFor(plugin, 1u); + ASSERT_TRUE(lane.has_value()); + ASSERT_TRUE(loop.WireEditorAutomationLane(*lane, plugin, 1u)); + + // 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); + 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); + + // 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); + 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(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; + 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(2u, count); + EXPECT_NEAR(0.10f, points[0].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); +} + +TEST(MidiAutomationPhaseAnchor, 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(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.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); +} diff --git a/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp b/test/JammaLib_Tests/src/midi/MidiLoop_Tests.cpp index eb28a8cc..237a2025 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) @@ -618,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)); @@ -626,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()); } @@ -922,84 +777,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 +909,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/vst/Vst2Plugin_Tests.cpp b/test/JammaLib_Tests/src/vst/Vst2Plugin_Tests.cpp index 19d6c392..6dc1fbfb 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.