diff --git a/CMakeLists.txt b/CMakeLists.txt index bcb5606be..e69383a7f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index 50690d903..bf8238163 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -245,6 +245,7 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see | | [`resize [target]`](#resize) | Resize a window (drives panadapter `x_pixels`). | | | [`window [target]`](#window) | maximize / restore / minimize / fullscreen. | | | [`shortcut `](#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 `](#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). | @@ -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. @@ -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. - + | Verb | Aliases | Description | |---|---|---| @@ -2167,6 +2183,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `resize` | — | resize [target] — resize a window | | `window` | — | window [target] | | `shortcut` | — | shortcut — fire a ShortcutManager/MIDI action (TX-gated) | +| `midi` | — | midi cc <0-127> — inject a learned VFO Tune Knob CC event | | `menu` | — | menu list \| open — menu-bar menus | | `whoami` | — | bridge instance info: pid, socket, label, station, txAllowed | | `log` | — | log [args] | diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 867756671..59e5ea27e 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -2729,6 +2729,12 @@ const std::vector& AutomationServer::verbRegistry() 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 — menu-bar menus", parseActionRest, [](AutomationServer& s, A& a, QLocalSocket*) { @@ -5584,6 +5590,47 @@ QJsonObject AutomationServer::doShortcut(const QString& id) const }; } +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 diff --git a/src/core/AutomationServer.h b/src/core/AutomationServer.h index fb95ec7f3..5b29aba03 100644 --- a/src/core/AutomationServer.h +++ b/src/core/AutomationServer.h @@ -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. diff --git a/src/core/MidiControlManager.cpp b/src/core/MidiControlManager.cpp index 34e410cde..c3fc5fdef 100644 --- a/src/core/MidiControlManager.cpp +++ b/src/core/MidiControlManager.cpp @@ -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) { + 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: diff --git a/src/core/MidiControlManager.h b/src/core/MidiControlManager.h index 7c36b74fb..007cf0ba3 100644 --- a/src/core/MidiControlManager.h +++ b/src/core/MidiControlManager.h @@ -11,6 +11,8 @@ #include #include +#include "MidiRelativeCcDecoder.h" + namespace AetherSDR { // ── Data types ────────────────────────────────────────────────────────────── @@ -83,6 +85,10 @@ class MidiControlManager : public QObject { const QVector& 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(); @@ -123,6 +129,7 @@ class MidiControlManager : public QObject { QVector m_bindings; QHash m_bindingIndex; // binding key → index into m_bindings + QHash m_relativeCcEncodings; bool m_learning{false}; QString m_learnParamId; @@ -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 diff --git a/src/core/MidiRelativeCcDecoder.h b/src/core/MidiRelativeCcDecoder.h new file mode 100644 index 000000000..198fa32af --- /dev/null +++ b/src/core/MidiRelativeCcDecoder.h @@ -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) { + encoding = MidiRelativeCcEncoding::TwosComplement; + } + } + + if (encoding == MidiRelativeCcEncoding::Center64) { + return {value - 64, encoding}; + } + if (encoding == MidiRelativeCcEncoding::TwosComplement) { + return {(value < 64) ? value : (value - 128), encoding}; + } + return {0, encoding}; +} + +} // namespace AetherSDR diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 48e8a5d73..f0fb3e251 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -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); diff --git a/src/gui/MainWindow_Controllers.cpp b/src/gui/MainWindow_Controllers.cpp index c8c900816..141204e1f 100644 --- a/src/gui/MainWindow_Controllers.cpp +++ b/src/gui/MainWindow_Controllers.cpp @@ -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; +#else + Q_UNUSED(value) + return 1; +#endif +} + void MainWindow::applyFlexControlWheelAction(const QString& actionId, int steps) { if (steps == 0) diff --git a/tests/midi_relative_cc_decoder_test.cpp b/tests/midi_relative_cc_decoder_test.cpp new file mode 100644 index 000000000..be68d9fb4 --- /dev/null +++ b/tests/midi_relative_cc_decoder_test.cpp @@ -0,0 +1,49 @@ +#include "core/MidiRelativeCcDecoder.h" + +#include + +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; +}