Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3421,6 +3421,12 @@ target_include_directories(midi_settings_test PRIVATE src)
target_link_libraries(midi_settings_test PRIVATE Qt6::Core)
add_test(NAME midi_settings_test COMMAND midi_settings_test)

add_executable(midi_relative_cc_decoder_test
tests/midi_relative_cc_decoder_test.cpp
)
target_include_directories(midi_relative_cc_decoder_test PRIVATE src)
add_test(NAME midi_relative_cc_decoder_test COMMAND midi_relative_cc_decoder_test)

add_executable(transmit_model_test
tests/transmit_model_test.cpp
src/models/TransmitModel.cpp
Expand Down
19 changes: 18 additions & 1 deletion docs/automation-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see
| | [`resize <w> <h> [target]`](#resize) | Resize a window (drives panadapter `x_pixels`). |
| | [`window <state> [target]`](#window) | maximize / restore / minimize / fullscreen. |
| | [`shortcut <id>`](#shortcut) | Fire a ShortcutManager/MIDI action by id (TX-guarded). |
| | [`midi cc <0-127>`](#midi) | Inject a learned VFO Tune Knob CC event (RX-only). |
| | [`scrollTo <target>`](#scrollto-alias-ensurevisible) | Scroll a widget into its scroll-area viewport. |
| **State (`get`)** | [`get audio`](#get) | Audio-engine stream/buffer snapshot. |
| | [`get dsp`](#get-dsp) | Client-side AetherDSP NR state (NR2…BNR). |
Expand Down Expand Up @@ -1273,6 +1274,21 @@ needs key **release** edges. The bridge replies with a distinct
truth, no bridge-side id list to drift. RX-only actions (the zoom shortcuts
included) need no flag.

### `midi`
Inject one MIDI Control Change value through the same learned VFO Tune Knob
relative decoder used by physical controllers. This focused automation surface
does not create or persist a binding and is RX-only.

```json
→ {"cmd":"midi","action":"cc","value":"65"}
← {"ok":true,"midi":"cc","value":65,"paramId":"rx.tuneKnob","accepted":true}
```

Bare form: `midi cc 65`. Use `get slice active` before and after the injection
to assert that center-64 values 65 and 63 move exactly one configured tuning
step in opposite directions. The controller manager coalesces events for 20 ms,
so callers should wait briefly before reading the resulting slice frequency.

### `pan`
Panadapter lifecycle — create or tear down a pan regardless of how it was opened.

Expand Down Expand Up @@ -2122,7 +2138,7 @@ lands.
The complete registry, generated from the `add(...)` table in `AutomationServer.cpp` by `tools/gen_bridge_docs.py`. CI fails if this drifts from the code.

<!-- BEGIN GENERATED VERB TABLE (tools/gen_bridge_docs.py) -->
<!-- Do not edit by hand — run tools/gen_bridge_docs.py. 46 verbs. -->
<!-- Do not edit by hand — run tools/gen_bridge_docs.py. 47 verbs. -->

| Verb | Aliases | Description |
|---|---|---|
Expand Down Expand Up @@ -2167,6 +2183,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer.
| `resize` | — | resize <w> <h> [target] — resize a window |
| `window` | — | window <maximize\|restore\|minimize\|fullscreen> [target] |
| `shortcut` | — | shortcut <id> — fire a ShortcutManager/MIDI action (TX-gated) |
| `midi` | — | midi cc <0-127> — inject a learned VFO Tune Knob CC event |
| `menu` | — | menu list \| open <name> — menu-bar menus |
| `whoami` | — | bridge instance info: pid, socket, label, station, txAllowed |
| `log` | — | log <categories\|get\|set\|reset\|tail\|subscribe\|unsubscribe> [args] |
Expand Down
47 changes: 47 additions & 0 deletions src/core/AutomationServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
#include <QAction>
#include <QLocalServer>
#include <QLocalSocket>
#include <QApplication>

Check warning on line 19 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QApplication) — tracked legacy (baseline 20); the count may only shrink
#include <QScreen>
#include <QWidget>

Check warning on line 21 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QWidget) — tracked legacy (baseline 20); the count may only shrink
#include <QMainWindow>

Check warning on line 22 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMainWindow) — tracked legacy (baseline 20); the count may only shrink
#include <QMenu>

Check warning on line 23 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMenu) — tracked legacy (baseline 20); the count may only shrink
#include <QMenuBar>

Check warning on line 24 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMenuBar) — tracked legacy (baseline 20); the count may only shrink
#include <QTabBar>

Check warning on line 25 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QTabBar) — tracked legacy (baseline 20); the count may only shrink
#include <QEnterEvent>
#include <QMouseEvent>
#include <QWheelEvent>
Expand Down Expand Up @@ -53,11 +53,11 @@
#include <limits>

// Best-effort value extraction for common control types.
#include <QAbstractButton>

Check warning on line 56 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractButton) — tracked legacy (baseline 20); the count may only shrink
#include <QAbstractSlider>

Check warning on line 57 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractSlider) — tracked legacy (baseline 20); the count may only shrink
#include <QAbstractItemView> // invoke selectRow: QTableWidget/QTreeWidget/QListWidget row select

Check warning on line 58 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractItemView) — tracked legacy (baseline 20); the count may only shrink
#include <QItemSelectionModel>
#include <QComboBox>

Check warning on line 60 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QComboBox) — tracked legacy (baseline 20); the count may only shrink
#include <QLineEdit>
#include <QLabel>
#include <QSpinBox>
Expand Down Expand Up @@ -2729,6 +2729,12 @@
return s.doShortcut(a.target.isEmpty() ? a.id : a.target);
});

add("midi", {}, "midi cc <0-127> — inject a learned VFO Tune Knob CC event",
parseActionValue,
[](AutomationServer& s, A& a, QLocalSocket*) {
return s.doMidi(a.action, a.value);
});

add("menu", {}, "menu list | open <name> — menu-bar menus",
parseActionRest,
[](AutomationServer& s, A& a, QLocalSocket*) {
Expand Down Expand Up @@ -5584,6 +5590,47 @@
};
}

QJsonObject AutomationServer::doMidi(const QString& action, const QString& value) const
{
if (action.compare(QStringLiteral("cc"), Qt::CaseInsensitive) != 0) {
return err(QStringLiteral("midi requires 'cc <0-127>'"));
}

bool okValue = false;
const int ccValue = value.toInt(&okValue);
if (!okValue || ccValue < 0 || ccValue > 127) {
return err(QStringLiteral("midi cc value must be an integer from 0 to 127"));
}

QWidget* mw = primaryTopLevelWindow();
if (!mw) {
return err(QStringLiteral("no main window to dispatch MIDI CC"));
}

int result = -1;
const bool invoked = QMetaObject::invokeMethod(
mw, "injectMidiVfoCcForAutomation", Qt::DirectConnection,
Q_RETURN_ARG(int, result), Q_ARG(int, ccValue));
if (!invoked) {
return err(QStringLiteral("MIDI automation injection is unavailable"));
}
if (result == 1) {
return err(QStringLiteral("MIDI support is unavailable in this build"));
}
if (result != 0) {
return err(QStringLiteral("MIDI CC value was rejected"));
}

qCInfo(lcAutomation).noquote() << "MIDI VFO CC injected:" << ccValue;
return QJsonObject{
{QStringLiteral("ok"), true},
{QStringLiteral("midi"), QStringLiteral("cc")},
{QStringLiteral("value"), ccValue},
{QStringLiteral("paramId"), QStringLiteral("rx.tuneKnob")},
{QStringLiteral("accepted"), true},
};
}

// ── Headless render size (#3646 fidelity — item 8) ──────────────────────────
// Resize a top-level window so the panadapter x_pixels (== SpectrumWidget
// width) propagates to a realistic value — under QT_QPA_PLATFORM=offscreen the
Expand Down
3 changes: 3 additions & 0 deletions src/core/AutomationServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,9 @@ private slots:
// for actions with no key sequence and no menu entry (Band Zoom, Segment
// Zoom, …). TX-keying ids stay behind AETHER_AUTOMATION_ALLOW_TX. (#4057)
QJsonObject doShortcut(const QString& id) const;
// Inject a learned VFO Tune Knob MIDI CC value through the controller
// decoder. Automation-only, RX-only, and never persists a binding.
QJsonObject doMidi(const QString& action, const QString& value) const;
// Resolve the top-level window a window-scoped verb (resize/window) acts on:
// the target's window() if given, else the QMainWindow (or first visible real
// top-level). Shared by doResize and doWindow.
Expand Down
61 changes: 47 additions & 14 deletions src/core/MidiControlManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Comment on lines +392 to +396

Copy link
Copy Markdown
Collaborator

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. startLearn always sets binding.relative = isVfoTuneKnobParamId(...) && CC, so every newly learned VFO CC binding takes the binding.relative branch — 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 → −1 reversed, 0 → 0 dead). Either the comment should say so, or binary encoders need real handling (they can't just reuse Tier 1, since 127 collides between binary-CW and two's-complement-−1).

Suggested change
// 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.
// Learned VFO bindings auto-detect the two common encodings from the first
// *unit* detent: 1/127 → two's-complement, 63/65 → center-64. Other
// relative parameters keep two's-complement. NOTE: learned VFO CC bindings
// are always marked relative (startLearn), so Tier 1/2 below are NOT reached
// for them — a binary/Thetis (0/127) encoder learned on the VFO is decoded
// as two's-complement here (127 → −1). Genuine binary support needs its own
// encoding, not the Tier-1 fall-through (127 collides with two's-comp −1).

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;
}

Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor behavior change — delta == 0 now skips the paramValueChanged(paramId, 0.0f) emit that the old explicit-relative path emitted for value == 0 on non-VFO relative params. Almost certainly harmless (and arguably cleaner), but flagging in case any consumer watches that signal for a zero pulse.

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:
Expand Down
8 changes: 8 additions & 0 deletions src/core/MidiControlManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
#include <memory>
#include <RtMidi.h>

#include "MidiRelativeCcDecoder.h"

namespace AetherSDR {

// ── Data types ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -83,6 +85,10 @@ class MidiControlManager : public QObject {
const QVector<MidiBinding>& bindings() const { return m_bindings; }
void rebuildIndex();

// Automation-only injection point used by the agent bridge to exercise the
// same learned VFO-relative decoder without requiring physical MIDI input.
Q_INVOKABLE bool injectVfoCcForAutomation(int value);

// MIDI Learn
void startLearn(const QString& paramId);
void cancelLearn();
Expand Down Expand Up @@ -123,6 +129,7 @@ class MidiControlManager : public QObject {

QVector<MidiBinding> m_bindings;
QHash<quint32, int> m_bindingIndex; // binding key → index into m_bindings
QHash<quint32, MidiRelativeCcEncoding> m_relativeCcEncodings;

bool m_learning{false};
QString m_learnParamId;
Expand All @@ -139,6 +146,7 @@ class MidiControlManager : public QObject {
QTimer* m_relativeTimer{nullptr};
void accumulateRelativeStep(const QString& paramId, int delta);
void flushRelativeAccum();
void dispatchRelativeCc(const MidiBinding& binding, int value);
};

} // namespace AetherSDR
Expand Down
40 changes: 40 additions & 0 deletions src/core/MidiRelativeCcDecoder.h
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encoding is locked from the first directional value, and only 63/65 select center-64 — any other non-64 first value falls to two's-complement and stays there. Two realistic mis-locks:

  • A center-64 encoder whose first movement is a fast/multi-step detent (e.g. 66 or 62) is classified two's-complement, then 66 decodes as 66-128 = -62 (big backward jump) and stays wrong.
  • A two's-complement encoder whose first value happens to be 63/65 (a 63-step spin) locks to center-64.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 66/62/67/61…), this falls into the else if (value != 64) branch and permanently locks TwosComplement — then 66 decodes as 66-128 = -62, a large jump in the wrong direction, for the rest of the binding's life.

Since two's-complement small moves cluster near 0/128 and center-64 small moves cluster near 64, you can disambiguate on a band around center without ambiguity (a genuine two's-complement value in 60..68 would be a ~60-step first flick — implausible as an initial event):

Suggested change
if (value == 63 || value == 65) {
encoding = MidiRelativeCcEncoding::Center64;
} else if (value != 64) {
if (value >= 60 && value <= 68 && value != 64) {
encoding = MidiRelativeCcEncoding::Center64;
} else if (value != 64) {

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.

@skerker skerker Jul 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

@jensenpat jensenpat Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 else if (value != 64) locks two's-complement on any other first sample. A center-64 controller whose first move isn't a single click (e.g. a quick spin → value 70) locks two's-complement, and every later 63/65 detent then decodes to ∓63 — the #4096 jumps, made permanent with no recovery short of re-learning. Proven by compiling this header standalone.

Fix: only lock on a definitive discriminator; defer (delta 0, stay Undetermined) on an ambiguous first sample until a real unit detent arrives. Verified this still passes all existing tests.

Suggested change
if (encoding == MidiRelativeCcEncoding::Undetermined) {
if (value == 63 || value == 65) {
encoding = MidiRelativeCcEncoding::Center64;
} else if (value != 64) {
encoding = MidiRelativeCcEncoding::TwosComplement;
}
}
if (encoding == MidiRelativeCcEncoding::Undetermined) {
// Only lock on a *definitive* single-detent discriminator. The two
// encodings' unit values are disjoint (1/127 vs 63/65); any other
// first sample (e.g. a fast first turn) is ambiguous and must NOT
// commit an encoding — otherwise a center-64 controller whose first
// move isn't a unit detent locks two's-complement and every later
// 63/65 detent decodes to ∓63 (the #4096 jumps, made permanent).
if (value == 63 || value == 65) {
encoding = MidiRelativeCcEncoding::Center64;
} else if (value == 1 || value == 127) {
encoding = MidiRelativeCcEncoding::TwosComplement;
} else {
return {0, encoding}; // stay Undetermined until a unit detent
}
}


if (encoding == MidiRelativeCcEncoding::Center64) {
return {value - 64, encoding};
}
if (encoding == MidiRelativeCcEncoding::TwosComplement) {
return {(value < 64) ? value : (value - 128), encoding};
}
return {0, encoding};
}

} // namespace AetherSDR
4 changes: 4 additions & 0 deletions src/gui/MainWindow.h
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ class MainWindow : public QMainWindow {
// actions registered keysTx (the caller decides policy; the registration
// site declares the data). Returns a ShortcutFire* code.
Q_INVOKABLE int fireShortcutAction(const QString& id, bool allowTx);
// Inject one learned VFO-knob CC value through MidiControlManager for
// automation proof. Returns 0 on acceptance, 1 if MIDI is unavailable,
// and 2 for an out-of-range MIDI value.
Q_INVOKABLE int injectMidiVfoCcForAutomation(int value);
QJsonObject automationSetSliceReceiveSource(const QString& arg);
QJsonObject automationSetCenterLock(int sliceId, bool enabled);
QJsonObject automationTune(double mhz);
Expand Down
21 changes: 21 additions & 0 deletions src/gui/MainWindow_Controllers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Unconditional BlockingQueuedConnection skips the codebase's same-thread guard. The established idiom (e.g. AutomationServer.cpp ~1590) checks !t || t == QThread::currentThread() and only block-queues when genuinely cross-thread. It works today because MIDI lives on m_extCtrlThread, but it's a latent GUI hang: a same-thread caller would deadlock, and a dead/quitting ext-ctrl loop (teardown) would block the GUI forever. Match the idiom (needs #include <QThread> if not already present):

Suggested change
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;
// Only block-queue when m_midiControl is genuinely on another thread —
// a same-thread BlockingQueuedConnection deadlocks, and a dead/quitting
// ext-ctrl loop would hang the GUI (cf. AutomationServer.cpp ~1590).
QThread* mgrThread = m_midiControl->thread();
const Qt::ConnectionType conn =
(!mgrThread || mgrThread == QThread::currentThread())
? Qt::DirectConnection : Qt::BlockingQueuedConnection;
bool accepted = false;
const bool invoked = QMetaObject::invokeMethod(
m_midiControl, "injectVfoCcForAutomation", conn,
Q_RETURN_ARG(bool, accepted), Q_ARG(int, value));
return invoked && accepted ? 0 : 1;

#else
Q_UNUSED(value)
return 1;
#endif
}

void MainWindow::applyFlexControlWheelAction(const QString& actionId, int steps)
{
if (steps == 0)
Expand Down
49 changes: 49 additions & 0 deletions tests/midi_relative_cc_decoder_test.cpp
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
return ok ? 0 : 1;
// Ambiguous first value must NOT lock an encoding (#4096 regression guard):
// a center-64 controller whose first move isn't a unit detent would else
// lock two's-complement and decode every later detent to ∓63.
encoding = MidiRelativeCcEncoding::Undetermined;
result = decodeMidiRelativeCc(70, encoding);
encoding = result.encoding;
ok &= expect(result.delta == 0, "ambiguous first value yields no step");
ok &= expect(encoding == MidiRelativeCcEncoding::Undetermined,
"ambiguous first value does not lock an encoding");
result = decodeMidiRelativeCc(65, encoding);
encoding = result.encoding;
ok &= expect(result.delta == 1 && encoding == MidiRelativeCcEncoding::Center64,
"first real detent still selects center-64 (+1)");
return ok ? 0 : 1;

}
Loading