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