Skip to content

Commit e640370

Browse files
committed
midimapping: tap tempo can also map with CC
1 parent 8f70fbd commit e640370

7 files changed

Lines changed: 233 additions & 16 deletions

File tree

include/element/midimapping.hpp

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
#include <element/model.hpp>
99
#include <element/tags.hpp>
1010

11-
#define EL_MIDIMAPPING_VERSION 1
11+
#define EL_MIDIMAPPING_VERSION 2
1212

1313
namespace element {
1414

@@ -54,9 +54,39 @@ class MidiMapping : public Model {
5454
/** For note events: latch on each note-on vs momentary. */
5555
bool isToggle() const { return (bool) objectData.getProperty (tags::toggle, false); }
5656

57+
/** For controller events on a trigger-style target (tap tempo): what counts as
58+
a trigger. One of "above" (the value crosses up to or through
59+
getTriggerValue()), "zero" (the value reaches 0) or "max" (the value
60+
reaches 127). Meaningless for note events. */
61+
juce::String getTriggerMode() const { return objectData.getProperty (tags::triggerMode, "above").toString(); }
62+
/** Threshold used by the "above" trigger mode, 0-127. */
63+
int getTriggerValue() const { return (int) objectData.getProperty (tags::triggerValue, 67); }
64+
5765
bool isNoteEvent() const { return getEventType() == "note"; }
5866
bool isControllerEvent() const { return getEventType() == "controller"; }
5967

68+
/** Decides whether a controller value transition fires a trigger.
69+
70+
A continuous controller has no note-on, so a trigger is an edge: the value
71+
has to arrive at the mode's condition from somewhere that did not satisfy
72+
it. Holding a knob past the threshold therefore fires once, not on every
73+
message, and a footswitch sending 127 then 0 fires once per press.
74+
75+
@param mode "above", "zero" or "max"; anything else behaves as "above".
76+
@param triggerValue Threshold for "above" mode; ignored by the other modes.
77+
@param lastValue Previously seen controller value, or < 0 if none yet.
78+
@param value The incoming controller value.
79+
@return true if this transition should fire the trigger.
80+
*/
81+
static bool isTriggerEdge (const juce::String& mode, int triggerValue, int lastValue, int value)
82+
{
83+
if (mode == "zero")
84+
return value == 0 && lastValue != 0;
85+
if (mode == "max")
86+
return value == 127 && lastValue != 127;
87+
return value >= triggerValue && (lastValue < 0 || lastValue < triggerValue);
88+
}
89+
6090
//=========================================================================
6191
juce::String getTargetType() const { return objectData.getProperty (tags::targetType).toString(); }
6292
bool isTempoTarget() const { return getTargetType() == "tempo"; }
@@ -142,6 +172,8 @@ class MidiMapping : public Model {
142172
stabilizePropertyPOD (tags::eventId, 0);
143173
stabilizePropertyPOD (tags::midiChannel, 0);
144174
stabilizePropertyPOD (tags::toggle, false);
175+
stabilizePropertyString (tags::triggerMode, "above");
176+
stabilizePropertyPOD (tags::triggerValue, 67);
145177
stabilizePropertyString (tags::targetType, "parameter");
146178
stabilizePropertyString (tags::node, juce::String());
147179
stabilizePropertyPOD (tags::parameter, -1);

include/element/tags.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ static const juce::Identifier eventType = "eventType";
195195
static const juce::Identifier eventId = "eventId";
196196
static const juce::Identifier toggle = "toggle";
197197
static const juce::Identifier targetType = "targetType";
198+
static const juce::Identifier triggerMode = "triggerMode";
199+
static const juce::Identifier triggerValue = "triggerValue";
198200
} // namespace tags
199201

200202
} // namespace element

src/engine/mappingtarget.cpp

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,16 @@ void ParameterTarget::applyGain (const juce::MidiMessage& message, bool toggle)
167167
}
168168

169169
//=============================================================================
170-
TempoTarget::TempoTarget (const juce::ValueTree& sessionData, TapTempo& shared, Signal<void()>& applied)
171-
: session (sessionData), tapTempo (shared), tempoTapApplied (applied)
170+
TempoTarget::TempoTarget (const juce::ValueTree& sessionData,
171+
TapTempo& shared,
172+
Signal<void()>& applied,
173+
const juce::String& mode,
174+
int value)
175+
: session (sessionData),
176+
tapTempo (shared),
177+
tempoTapApplied (applied),
178+
triggerMode (mode),
179+
triggerValue (value)
172180
{
173181
}
174182

@@ -179,8 +187,21 @@ bool TempoTarget::isValid() const
179187

180188
void TempoTarget::apply (const juce::MidiMessage& message, bool /*toggle*/)
181189
{
182-
if (! isValid() || ! message.isNoteOn())
183-
return; // note-on only: each press is a tap
190+
if (! isValid())
191+
return;
192+
193+
if (message.isController())
194+
{
195+
const int value = message.getControllerValue();
196+
const bool fire = MidiMapping::isTriggerEdge (triggerMode, triggerValue, lastControllerValue, value);
197+
lastControllerValue = value;
198+
if (! fire)
199+
return;
200+
}
201+
else if (! message.isNoteOn())
202+
{
203+
return; // notes: each press is a tap
204+
}
184205

185206
// Flash on every recognised tap (including the seeding first tap of a run,
186207
// which produces no BPM yet), so the UI feedback matches how a parameter
@@ -211,7 +232,7 @@ std::unique_ptr<MappingTarget> createTarget (const MidiMapping& mapping, Session
211232

212233
if (targetType == "tempo")
213234
{
214-
auto target = std::make_unique<TempoTarget> (session.data(), tapTempo, tempoTapApplied);
235+
auto target = std::make_unique<TempoTarget> (session.data(), tapTempo, tempoTapApplied, mapping.getTriggerMode(), mapping.getTriggerValue());
215236
if (! target->isValid())
216237
return nullptr;
217238
return target;

src/engine/mappingtarget.hpp

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@ class ParameterTarget : public MappingTarget
6262
};
6363

6464
//=============================================================================
65-
/** Targets the session tempo as a tap-tempo control: each matching note-on
66-
counts as a tap and the averaged BPM is written to the session, which the
67-
audio engine picks up on the message thread. */
65+
/** Targets the session tempo as a tap-tempo control: each matching note-on, or
66+
each recognised controller edge, counts as a tap and the averaged BPM is
67+
written to the session, which the audio engine picks up on the message
68+
thread. */
6869
class TempoTarget : public MappingTarget
6970
{
7071
public:
@@ -73,8 +74,14 @@ class TempoTarget : public MappingTarget
7374
so UI and MIDI taps contribute to the same state.
7475
@param tempoTapApplied Fired on every recognised tap so the UI can flash
7576
the TAP button; owned by MappingEngine, so it
76-
outlives this target. */
77-
TempoTarget (const juce::ValueTree& sessionData, TapTempo& tapTempo, Signal<void()>& tempoTapApplied);
77+
outlives this target.
78+
@param triggerMode Controller trigger mode, see MidiMapping::isTriggerEdge().
79+
@param triggerValue Threshold for the "above" trigger mode. */
80+
TempoTarget (const juce::ValueTree& sessionData,
81+
TapTempo& tapTempo,
82+
Signal<void()>& tempoTapApplied,
83+
const juce::String& triggerMode = "above",
84+
int triggerValue = 67);
7885
~TempoTarget() override = default;
7986

8087
bool isValid() const override;
@@ -84,6 +91,9 @@ class TempoTarget : public MappingTarget
8491
juce::ValueTree session;
8592
TapTempo& tapTempo;
8693
Signal<void()>& tempoTapApplied;
94+
juce::String triggerMode;
95+
int triggerValue;
96+
int lastControllerValue { -1 }; // edge state, so a held knob taps only once
8797
};
8898

8999
//=============================================================================

src/ui/midimappingsview.cpp

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,23 @@ class MidiMappingProperties : public juce::PropertyPanel,
216216

217217
// Latch is meaningful for notes only.
218218
if (mapping.isNoteEvent())
219+
{
219220
comps.add (new BooleanPropertyComponent (
220221
mapping.getPropertyAsValue (tags::toggle), TRANS ("Latch"), TRANS ("Toggle on each note-on")));
222+
}
223+
else if (mapping.isTempoTarget())
224+
{
225+
// A knob or switch has no note-on, so the user picks what counts as a tap.
226+
comps.add (new ChoicePropertyComponent (
227+
mapping.getPropertyAsValue (tags::triggerMode),
228+
TRANS ("Trigger"),
229+
{ TRANS ("At or Above"), TRANS ("Touched 0"), TRANS ("Touched 127") },
230+
{ var ("above"), var ("zero"), var ("max") }));
231+
232+
if (mapping.getTriggerMode() == "above")
233+
comps.add (new SliderPropertyComponent (
234+
mapping.getPropertyAsValue (tags::triggerValue), TRANS ("Threshold"), 1.0, 127.0, 1.0));
235+
}
221236

222237
// Node + parameter only apply to parameter targets; a tempo mapping
223238
// drives the session tempo (tap tempo) and has no node/parameter.
@@ -323,9 +338,10 @@ class MidiMappingProperties : public juce::PropertyPanel,
323338
if (onEdited)
324339
onEdited();
325340

326-
// Event type flips the CC/Note label and Latch row; node changes the
327-
// parameter list. Rebuild async so we never delete the editor mid-callback.
328-
if (property == tags::eventType || property == tags::node)
341+
// Event type flips the CC/Note label and the Latch/Trigger rows; the
342+
// trigger mode shows or hides Threshold; node changes the parameter list.
343+
// Rebuild async so we never delete the editor mid-callback.
344+
if (property == tags::eventType || property == tags::node || property == tags::triggerMode)
329345
{
330346
juce::Component::SafePointer<MidiMappingProperties> self (this);
331347
juce::MessageManager::callAsync ([self]() mutable {

test/MappingTargetTests.cpp

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,16 +162,122 @@ BOOST_AUTO_TEST_CASE (TempoTargetTapsFromNoteOn)
162162
BOOST_REQUIRE_CLOSE ((double) session.getProperty (tags::tempo), 120.0, 0.0001);
163163
BOOST_REQUIRE_EQUAL (flashes, 2);
164164

165-
// Note-off and CC never tap: tempo stays put and the flash does not fire.
165+
// Note-off never taps: tempo stays put and the flash does not fire.
166166
session.setProperty (tags::tempo, 100.0, nullptr);
167167
target.apply (MidiMessage::noteOff (1, 60), false);
168-
target.apply (MidiMessage::controllerEvent (1, 7, 127), false);
169168
BOOST_REQUIRE_CLOSE ((double) session.getProperty (tags::tempo), 100.0, 0.0001);
170169
BOOST_REQUIRE_EQUAL (flashes, 2);
171170

172171
conn.disconnect();
173172
}
174173

174+
namespace {
175+
176+
/** A tempo target plus the session tree and flash counter it writes to, so the
177+
controller cases below stay about the trigger rule and nothing else. */
178+
struct TempoFixture
179+
{
180+
TempoFixture (const juce::String& mode = "above", int value = 67)
181+
: target (session, shared, tapped, mode, value)
182+
{
183+
session.setProperty (tags::tempo, 100.0, nullptr);
184+
conn = tapped.connect ([this] { ++flashes; });
185+
}
186+
187+
~TempoFixture() { conn.disconnect(); }
188+
189+
/** Apply a CC on the mapped controller at the given arrival time. */
190+
void cc (int value, double ms = 0.0)
191+
{
192+
auto m = MidiMessage::controllerEvent (1, 7, value);
193+
m.setTimeStamp (ms);
194+
target.apply (m, false);
195+
}
196+
197+
double tempo() const { return (double) session.getProperty (tags::tempo); }
198+
199+
ValueTree session { types::Session };
200+
TapTempo shared;
201+
Signal<void()> tapped;
202+
SignalConnection conn;
203+
int flashes = 0;
204+
TempoTarget target;
205+
};
206+
207+
} // namespace
208+
209+
BOOST_AUTO_TEST_CASE (TempoTargetTapsFromCCAboveThreshold)
210+
{
211+
// Default rule: a tap fires when the value crosses up to or through 67.
212+
// Twisting back and forth taps once per upward pass, so 500 ms between
213+
// crossings is 120 BPM.
214+
TempoFixture f;
215+
216+
f.cc (0, 0.0); // below the threshold: not a tap
217+
BOOST_REQUIRE_EQUAL (f.flashes, 0);
218+
219+
f.cc (80, 100.0); // crossed up: seeds the run
220+
BOOST_REQUIRE_EQUAL (f.flashes, 1);
221+
BOOST_REQUIRE_CLOSE (f.tempo(), 100.0, 0.0001);
222+
223+
f.cc (100, 200.0); // still above: no second tap
224+
f.cc (20, 400.0); // falling back down: no tap
225+
BOOST_REQUIRE_EQUAL (f.flashes, 1);
226+
227+
f.cc (90, 600.0); // crossed up again, 500 ms after the first tap
228+
BOOST_REQUIRE_EQUAL (f.flashes, 2);
229+
BOOST_REQUIRE_CLOSE (f.tempo(), 120.0, 0.0001);
230+
}
231+
232+
BOOST_AUTO_TEST_CASE (TempoTargetCCThresholdIsConfigurable)
233+
{
234+
TempoFixture f ("above", 100);
235+
236+
f.cc (80, 0.0); // above 67 but below the configured threshold
237+
BOOST_REQUIRE_EQUAL (f.flashes, 0);
238+
239+
f.cc (100, 100.0); // exactly at the threshold counts
240+
BOOST_REQUIRE_EQUAL (f.flashes, 1);
241+
}
242+
243+
BOOST_AUTO_TEST_CASE (TempoTargetCCTouchedZero)
244+
{
245+
TempoFixture f ("zero");
246+
247+
f.cc (127, 0.0); // never taps: only arriving at 0 does
248+
BOOST_REQUIRE_EQUAL (f.flashes, 0);
249+
250+
f.cc (0, 100.0);
251+
BOOST_REQUIRE_EQUAL (f.flashes, 1);
252+
253+
f.cc (0, 200.0); // parked at 0: no repeat
254+
BOOST_REQUIRE_EQUAL (f.flashes, 1);
255+
256+
f.cc (64, 500.0);
257+
f.cc (0, 600.0); // back to 0, 500 ms after the first tap
258+
BOOST_REQUIRE_EQUAL (f.flashes, 2);
259+
BOOST_REQUIRE_CLOSE (f.tempo(), 120.0, 0.0001);
260+
}
261+
262+
BOOST_AUTO_TEST_CASE (TempoTargetCCTouchedMax)
263+
{
264+
TempoFixture f ("max");
265+
266+
f.cc (0, 0.0);
267+
BOOST_REQUIRE_EQUAL (f.flashes, 0);
268+
269+
f.cc (127, 100.0);
270+
BOOST_REQUIRE_EQUAL (f.flashes, 1);
271+
272+
f.cc (127, 200.0); // parked at 127: no repeat
273+
BOOST_REQUIRE_EQUAL (f.flashes, 1);
274+
275+
f.cc (10, 500.0);
276+
f.cc (127, 600.0);
277+
BOOST_REQUIRE_EQUAL (f.flashes, 2);
278+
BOOST_REQUIRE_CLOSE (f.tempo(), 120.0, 0.0001);
279+
}
280+
175281
BOOST_AUTO_TEST_CASE (InvalidTargets)
176282
{
177283
ProcessorPtr obj = new ParamTestNode (1);

test/MidiMappingModelTests.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,40 @@ BOOST_AUTO_TEST_CASE (DefaultsStabilize)
2222
BOOST_REQUIRE_EQUAL (m.getEventId(), 0);
2323
BOOST_REQUIRE_EQUAL (m.getMidiChannel(), 0);
2424
BOOST_REQUIRE (! m.isToggle());
25+
BOOST_REQUIRE (m.getTriggerMode() == "above");
26+
BOOST_REQUIRE_EQUAL (m.getTriggerValue(), 67);
2527
BOOST_REQUIRE (m.getTargetType() == "parameter");
2628
BOOST_REQUIRE_EQUAL (m.getParameterIndex(), -1);
2729
}
2830

31+
BOOST_AUTO_TEST_CASE (TriggerEdgeAbove)
32+
{
33+
// Rising crossing only, so a knob held past the threshold fires once.
34+
BOOST_REQUIRE (MidiMapping::isTriggerEdge ("above", 67, -1, 80)); // no history: counts
35+
BOOST_REQUIRE (! MidiMapping::isTriggerEdge ("above", 67, -1, 20));
36+
BOOST_REQUIRE (MidiMapping::isTriggerEdge ("above", 67, 20, 67)); // at the threshold counts
37+
BOOST_REQUIRE (! MidiMapping::isTriggerEdge ("above", 67, 80, 100)); // already above
38+
BOOST_REQUIRE (! MidiMapping::isTriggerEdge ("above", 67, 100, 20)); // falling
39+
BOOST_REQUIRE (MidiMapping::isTriggerEdge ("above", 67, 20, 90));
40+
41+
// An unknown mode string behaves as "above".
42+
BOOST_REQUIRE (MidiMapping::isTriggerEdge ("bogus", 67, 20, 90));
43+
BOOST_REQUIRE (! MidiMapping::isTriggerEdge ("bogus", 67, 80, 100));
44+
}
45+
46+
BOOST_AUTO_TEST_CASE (TriggerEdgeEndpoints)
47+
{
48+
BOOST_REQUIRE (MidiMapping::isTriggerEdge ("zero", 67, 64, 0));
49+
BOOST_REQUIRE (MidiMapping::isTriggerEdge ("zero", 67, -1, 0));
50+
BOOST_REQUIRE (! MidiMapping::isTriggerEdge ("zero", 67, 0, 0)); // parked at 0
51+
BOOST_REQUIRE (! MidiMapping::isTriggerEdge ("zero", 67, 0, 127));
52+
53+
BOOST_REQUIRE (MidiMapping::isTriggerEdge ("max", 67, 64, 127));
54+
BOOST_REQUIRE (MidiMapping::isTriggerEdge ("max", 67, -1, 127));
55+
BOOST_REQUIRE (! MidiMapping::isTriggerEdge ("max", 67, 127, 127)); // parked at 127
56+
BOOST_REQUIRE (! MidiMapping::isTriggerEdge ("max", 67, 127, 64));
57+
}
58+
2959
BOOST_AUTO_TEST_CASE (FromCaptureController)
3060
{
3161
Uuid node;

0 commit comments

Comments
 (0)