-
-
Notifications
You must be signed in to change notification settings - Fork 84
[midi] Fix center-64 MIDI VFO tuning jumps (CTR2-Midi) #4271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -204,15 +204,33 @@ void MidiControlManager::clearBindings() | |
| { | ||
| m_bindings.clear(); | ||
| m_bindingIndex.clear(); | ||
| m_relativeCcEncodings.clear(); | ||
| } | ||
|
|
||
| void MidiControlManager::rebuildIndex() | ||
| { | ||
| m_bindingIndex.clear(); | ||
| m_relativeCcEncodings.clear(); | ||
| for (int i = 0; i < m_bindings.size(); ++i) | ||
| m_bindingIndex[m_bindings[i].key()] = i; | ||
| } | ||
|
|
||
| bool MidiControlManager::injectVfoCcForAutomation(int value) | ||
| { | ||
| if (value < 0 || value > 127) { | ||
| return false; | ||
| } | ||
|
|
||
| MidiBinding binding; | ||
| binding.channel = 0; | ||
| binding.msgType = MidiBinding::CC; | ||
| binding.number = 0; | ||
| binding.paramId = QStringLiteral("rx.tuneKnob"); | ||
| binding.relative = true; | ||
| dispatchRelativeCc(binding, value); | ||
| return true; | ||
| } | ||
|
|
||
| // ── MIDI Learn ────────────────────────────────────────────────────────────── | ||
|
|
||
| void MidiControlManager::startLearn(const QString& paramId) | ||
|
|
@@ -371,21 +389,13 @@ void MidiControlManager::onMidiMessage(int status, int data1, int data2, | |
|
|
||
| // ── Relative knob mode: decode delta and accumulate ──────────────── | ||
| // | ||
| // Explicit-relative bindings (user picked Relative in MIDI Learn) are | ||
| // assumed to use two's-complement encoding — that's what the relative | ||
| // flag has always meant, and overloading it with a binary-mode override | ||
| // for the VFO would silently flip CCW pulses (data2=127, two's-complement | ||
| // -1) to CW for users with existing two's-complement encoders bound to | ||
| // the VFO knob. Binary-mode encoders (data2 ∈ {0, 127}) instead fall | ||
| // through to the backward-compat Tier 1 below, which handles them | ||
| // without requiring the relative flag. | ||
| // Learned VFO bindings auto-detect the two common encodings from the first | ||
| // directional value: 1/127 remains two's-complement, while the distinctive | ||
| // 63/65 pair selects center-64. Other relative parameters retain the | ||
| // established two's-complement behavior. Binary-mode encoders fall through | ||
| // to the backward-compat Tier 1 below when not explicitly marked relative. | ||
| if (binding.relative && msgType == MidiBinding::CC) { | ||
| int delta = relativeCcDelta(data2); | ||
| if (binding.inverted) delta = -delta; | ||
|
|
||
| accumulateRelativeStep(binding.paramId, delta); | ||
|
|
||
| emit paramValueChanged(binding.paramId, delta > 0 ? 1.0f : 0.0f); | ||
| dispatchRelativeCc(binding, data2); | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -468,6 +478,29 @@ void MidiControlManager::onMidiMessage(int status, int data1, int data2, | |
| emit paramValueChanged(binding.paramId, value); | ||
| } | ||
|
|
||
| void MidiControlManager::dispatchRelativeCc(const MidiBinding& binding, int value) | ||
| { | ||
| int delta = 0; | ||
| if (isVfoTuneKnobParamId(binding.paramId)) { | ||
| MidiRelativeCcEncoding& encoding = m_relativeCcEncodings[binding.key()]; | ||
| const MidiRelativeCcDecodeResult result = decodeMidiRelativeCc(value, encoding); | ||
| encoding = result.encoding; | ||
| delta = result.delta; | ||
| } else { | ||
| delta = relativeCcDelta(value); | ||
| } | ||
|
|
||
| if (binding.inverted) { | ||
| delta = -delta; | ||
| } | ||
| if (delta == 0) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Minor behavior change — |
||
| return; | ||
| } | ||
|
|
||
| accumulateRelativeStep(binding.paramId, delta); | ||
| emit paramValueChanged(binding.paramId, delta > 0 ? 1.0f : 0.0f); | ||
| } | ||
|
|
||
| // ── Relative knob coalescing ─────────────────────────────────────────────── | ||
| // Called every 20ms. VFO tuning is emitted as exact accumulated detents. | ||
| // Other relative controls keep the legacy density-based acceleration: | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| #pragma once | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| namespace AetherSDR { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| enum class MidiRelativeCcEncoding { | ||||||||||||||||||||||||||||||||||||||||||||||
| Undetermined, | ||||||||||||||||||||||||||||||||||||||||||||||
| TwosComplement, | ||||||||||||||||||||||||||||||||||||||||||||||
| Center64, | ||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| struct MidiRelativeCcDecodeResult { | ||||||||||||||||||||||||||||||||||||||||||||||
| int delta{0}; | ||||||||||||||||||||||||||||||||||||||||||||||
| MidiRelativeCcEncoding encoding{MidiRelativeCcEncoding::Undetermined}; | ||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Relative MIDI CC has no encoding marker on the wire. Preserve the established | ||||||||||||||||||||||||||||||||||||||||||||||
| // two's-complement 1/127 convention unless the first directional value is the | ||||||||||||||||||||||||||||||||||||||||||||||
| // distinctive 63/65 pair used by center-64 controllers such as CTR2-MIDI. | ||||||||||||||||||||||||||||||||||||||||||||||
| inline MidiRelativeCcDecodeResult decodeMidiRelativeCc( | ||||||||||||||||||||||||||||||||||||||||||||||
| int value, MidiRelativeCcEncoding currentEncoding) | ||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||
| MidiRelativeCcEncoding encoding = currentEncoding; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (encoding == MidiRelativeCcEncoding::Undetermined) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (value == 63 || value == 65) { | ||||||||||||||||||||||||||||||||||||||||||||||
| encoding = MidiRelativeCcEncoding::Center64; | ||||||||||||||||||||||||||||||||||||||||||||||
| } else if (value != 64) { | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+24
to
+26
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The encoding is locked from the first directional value, and only
Both are unlikely as a first event and the common single-detent case is handled, so this is fine to ship — but please note the heuristic limitation in a comment or the bridge doc so it's not surprising later. No code change required.
Comment on lines
+24
to
+26
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The auto-detect only recognizes the ±1 center-64 signature. If a center-64 controller's first directional event is ≥2 steps (value Since two's-complement small moves cluster near
Suggested change
Judgment call on the exact band width — not required for the CTR2-MIDI case this PR targets, but it makes first-movement detection robust to a fast initial detent.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jensenpat Saw the help wanted - not sure if you are looking for hardware testing? I was already in the process of testing my CTR2-MIDI this week, when I came across this PR. I've got a CTR2-MIDI on the bench and ran it through your midi cc bridge path: the fix works — slow first click locks center-64, and both slow and fast detents tune the right direction and amount (65→+100 Hz, 72→+800 Hz, and back). I confirmed the auto-detect edge case the bot flagged is real (a fast first value like 72 does lock two's-complement), but on this controller it's hard to hit in practice — the firmware's speed-tuning always sends ±1 first from rest, so a real first touch lands on 63/65 every time. One extra thing to note: if I change the knob's MIDI Type (WheelA→WheelB) without re-Learning, it tunes in an unexpected way — the binding stays locked to the encoding detected at the previous Learn, so a re-Learn is needed after any MIDI Type change for it to decode correctly. As I understand it, this PR decodes the two relative wheel encodings — WheelA (center-64) and WheelB (two's-complement) — so the user needs the knob set to WheelA or WheelB for VFO tuning. The CTR2 offers other MIDI Types too (sliders, a button, WheelB-r) that I haven't tested. Happy to share the capture logs and/or test further, if that's the help you are looking for? — authored by agent (Claude Code) on behalf of @skerker
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @skerker Thank you for testing and confirmation. Would you mind opening a separate issue on the re-learn behavior (and/or PR) since you have the wheel and can test please? Please feel free to pick that PR up if you have cycles.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jensenpat Happy to help - I'll submit a separate issue on the WheelA / WheelB re-learn behavior and work on the PR. |
||||||||||||||||||||||||||||||||||||||||||||||
| encoding = MidiRelativeCcEncoding::TwosComplement; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+23
to
+29
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocker — locks the encoding on an ambiguous first value. The two encodings' unit detents are disjoint (1/127 vs 63/65), but Fix: only lock on a definitive discriminator; defer (delta 0, stay
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (encoding == MidiRelativeCcEncoding::Center64) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return {value - 64, encoding}; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| if (encoding == MidiRelativeCcEncoding::TwosComplement) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return {(value < 64) ? value : (value - 128), encoding}; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| return {0, encoding}; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| } // namespace AetherSDR | ||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1292,6 +1292,27 @@ void MainWindow::refreshStreamDeckLabels() | |||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| #endif | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| int MainWindow::injectMidiVfoCcForAutomation(int value) | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| if (value < 0 || value > 127) { | ||||||||||||||||||||||||||||||||||||
| return 2; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| #ifdef HAVE_MIDI | ||||||||||||||||||||||||||||||||||||
| if (!m_midiControl) { | ||||||||||||||||||||||||||||||||||||
| return 1; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| bool accepted = false; | ||||||||||||||||||||||||||||||||||||
| const bool invoked = QMetaObject::invokeMethod( | ||||||||||||||||||||||||||||||||||||
| m_midiControl, "injectVfoCcForAutomation", Qt::BlockingQueuedConnection, | ||||||||||||||||||||||||||||||||||||
| Q_RETURN_ARG(bool, accepted), Q_ARG(int, value)); | ||||||||||||||||||||||||||||||||||||
| return invoked && accepted ? 0 : 1; | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1305
to
+1309
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Unconditional
Suggested change
|
||||||||||||||||||||||||||||||||||||
| #else | ||||||||||||||||||||||||||||||||||||
| Q_UNUSED(value) | ||||||||||||||||||||||||||||||||||||
| return 1; | ||||||||||||||||||||||||||||||||||||
| #endif | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| void MainWindow::applyFlexControlWheelAction(const QString& actionId, int steps) | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| if (steps == 0) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,49 @@ | ||||||||||||||||||||||||||||||||||||||
| #include "core/MidiRelativeCcDecoder.h" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| #include <iostream> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| using namespace AetherSDR; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| namespace { | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| bool expect(bool condition, const char* label) | ||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||
| std::cout << (condition ? "[ OK ] " : "[FAIL] ") << label << '\n'; | ||||||||||||||||||||||||||||||||||||||
| return condition; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| } // namespace | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| int main() | ||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||
| bool ok = true; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| MidiRelativeCcEncoding encoding = MidiRelativeCcEncoding::Undetermined; | ||||||||||||||||||||||||||||||||||||||
| MidiRelativeCcDecodeResult result = decodeMidiRelativeCc(65, encoding); | ||||||||||||||||||||||||||||||||||||||
| encoding = result.encoding; | ||||||||||||||||||||||||||||||||||||||
| ok &= expect(result.delta == 1, "CTR2 clockwise detent is one step"); | ||||||||||||||||||||||||||||||||||||||
| ok &= expect(encoding == MidiRelativeCcEncoding::Center64, | ||||||||||||||||||||||||||||||||||||||
| "CTR2 detent selects center-64 encoding"); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| result = decodeMidiRelativeCc(63, encoding); | ||||||||||||||||||||||||||||||||||||||
| ok &= expect(result.delta == -1, "CTR2 counter-clockwise detent is one step"); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| encoding = MidiRelativeCcEncoding::Undetermined; | ||||||||||||||||||||||||||||||||||||||
| result = decodeMidiRelativeCc(1, encoding); | ||||||||||||||||||||||||||||||||||||||
| encoding = result.encoding; | ||||||||||||||||||||||||||||||||||||||
| ok &= expect(result.delta == 1, "two's-complement clockwise pulse is preserved"); | ||||||||||||||||||||||||||||||||||||||
| ok &= expect(encoding == MidiRelativeCcEncoding::TwosComplement, | ||||||||||||||||||||||||||||||||||||||
| "unit pulse selects two's-complement encoding"); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| result = decodeMidiRelativeCc(127, encoding); | ||||||||||||||||||||||||||||||||||||||
| ok &= expect(result.delta == -1, | ||||||||||||||||||||||||||||||||||||||
| "two's-complement counter-clockwise pulse is preserved"); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| encoding = MidiRelativeCcEncoding::Undetermined; | ||||||||||||||||||||||||||||||||||||||
| result = decodeMidiRelativeCc(64, encoding); | ||||||||||||||||||||||||||||||||||||||
| ok &= expect(result.delta == 0, "center value is neutral before detection"); | ||||||||||||||||||||||||||||||||||||||
| ok &= expect(result.encoding == MidiRelativeCcEncoding::Undetermined, | ||||||||||||||||||||||||||||||||||||||
| "neutral value does not lock an encoding"); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return ok ? 0 : 1; | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+47
to
+48
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The test only exercises clean single detents — exactly the happy path that hides finding #1. Add coverage for the ambiguous-first-value case so the regression can't slip back in:
Suggested change
|
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 This comment is inaccurate for learned bindings, and it hides an unsupported case.
startLearnalways setsbinding.relative = isVfoTuneKnobParamId(...) && CC, so every newly learned VFO CC binding takes thebinding.relativebranch — Tier 1 (guarded by!binding.relative) is never reached for it. A binary/Thetis (0/127) encoder learned on the VFO therefore does not fall through; it's decoded as two's-complement here (127 → −1reversed,0 → 0dead). Either the comment should say so, or binary encoders need real handling (they can't just reuse Tier 1, since127collides between binary-CW and two's-complement-−1).