fix(tx): setAmCarrierLevel caches m_amCarrierLevel optimistically (#3711). Principle II.#3712
Conversation
). Principle II. setAmCarrierLevel() sent the radio command but never wrote m_amCarrierLevel, so amCarrierLevel() read stale until the radio's async am_carrier_level= echo arrived. It was the only TX setter missing the optimistic local update its siblings (setVoxLevel, setVoxDelay, setMicBoost, setMicBias, setDexpLevel) all perform. Mirror the sibling pattern: write the member behind a change guard and emit phoneStateChanged() (AM carrier is a phone/VOX-group property, parsed into phoneChanged in applyTransmitStatus). Keyword am_carrier is correct (FlexLib Radio.cs AMCarrierLevel) and unchanged. Principle II (radio authoritative on live state): the command has no synchronous echo, so the client may update optimistically; the radio's subsequent am_carrier_level= status remains the truth and supersedes. No feedback loop: PhoneApplet slider valueChanged guards on !m_updatingFromModel; syncFromModel (driven by phoneStateChanged) sets that flag and wraps setValue in a QSignalBlocker, so one user change yields one transmit set am_carrier command. Blast radius: risk_score=0.082, 4 high-risk affected (top: MainWindow::MainWindow, MainWindow::wireExternalControllers, MainWindow::registerMidiParams). All are construction/wiring or the MIDI param binding; they invoke the setter unchanged and only gain the optimistic read-back. Sole direct caller is PhoneApplet, verified loop-free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed — this looks good. ✅
The change correctly implements the optimistic-cache pattern (Principle II) already established by the sibling setters in this file:
- Matches the shape of
setVoxLevel/setVoxEnable/setMicBoost— cache locally,emit phoneStateChanged(), thencommandReady. - The
am_carrier_levelstatus-echo handler atTransmitModel.cpp:143-145re-applies the radio's value when it arrives, so the radio stays authoritative — the "echo supersedes" comment is accurate. - No feedback loop:
PhoneApplet::syncFromModel(thephoneStateChangedslot) wraps the slider update in aQSignalBlocker, so the optimistic emit can't re-entersetAmCarrierLevel. - The
if (m_amCarrierLevel != level)guard is a nice touch — slightly better than the unconditional siblings, since it avoids redundant signal emission when the value is unchanged.
No bugs, null risks, or resource concerns; scope is tight (one file, one setter). Thanks for the clean, well-commented fix! 🙏
🤖 aethersdr-agent · cost: $4.3853 · model: claude-opus-4-8
There was a problem hiding this comment.
Looks good — clean, correct, and faithful to Principle II. ✅
What it does: setAmCarrierLevel now caches m_amCarrierLevel optimistically and emits phoneStateChanged() before sending the command, so the AM carrier slider reflects user intent immediately instead of waiting on the radio.
Verified against the surrounding code:
- Right member, right signal.
m_amCarrierLevel(TransmitModel.h:316) is the field read by the getter (amCarrierLevel()) and the PhoneApplet slider (PhoneApplet.cpp:399-400), andphoneStateChanged()is the signal that applet listens on. Consistent with the existing optimistic setters right above it (setVoxLevel,setMicBoost,setMicBias). - Echo supersedes correctly (Principle II). The status path at
TransmitModel.cpp:143-145overwritesm_amCarrierLevelfromam_carrier_leveland flagsphoneChanged, so the radio's authoritative value still wins on the next status update. The optimistic write is genuinely just a UI-latency bridge. - Nice touch: the
if (m_amCarrierLevel != level)guard avoids redundantphoneStateChanged()emissions — slightly better than the unconditional emit in the neighboring setters.
No null/leak/boundary concerns (qBound clamps the input), scope is the single intended file, and nothing here touches a system boundary that needs added error handling. Thanks for the tidy fix!
🤖 aethersdr-agent · cost: $1.2398 · model: claude-opus-4-8
NF0T
left a comment
There was a problem hiding this comment.
Independent human review — @NF0T
Code verified against the current codebase. Approving.
Bug confirmed: Pre-PR setAmCarrierLevel sent the command but never wrote m_amCarrierLevel, leaving amCarrierLevel() stale until the radio's async am_carrier_level= echo arrived. Every adjacent phone-group setter (setVoxLevel, setVoxDelay, setMicBoost, setMicBias) cached locally before emitting — this was the sole omission.
Fix is correct:
- The optimistic write +
phoneStateChanged()emission are properly guarded insideif (m_amCarrierLevel != level), which is a minor improvement over the unconditional sibling pattern (avoids redundant signal emission on no-change). commandReadyfires unconditionally outside the guard — correct; the command goes to the radio regardless.- No feedback loop:
PhoneApplet::syncFromModelwrapssetValueinQSignalBlocker(m_amCarrierSlider)and the!m_updatingFromModelguard at line 124 provides belt-and-suspenders protection. One user interaction → one command. - The radio's
am_carrier_level=echo path atapplyTransmitStatus:143-145remains the truth and supersedes the optimistic value, satisfying Principle II as cited. - Command keyword
am_carrieris unchanged and correct per FlexLibRadio.cs AMCarrierLevel.
Scope is one file, one function, four lines. No regressions observed.
…ciple II. TransmitModel::setSbMonitor sent "transmit set mon=" but never updated its local m_sbMonitor, so transmitModel().sbMonitor() read stale until the radio echoed sb_monitor (measured non-convergent over a 410 ms window on a FLEX-8400M). Same defect class as #3712 (setAmCarrierLevel). Mirror the sibling pattern: write m_sbMonitor optimistically on change and emit micStateChanged() — the signal the MON button's model->widget sync (PhoneCwApplet::syncPhoneFromModel, connected to micStateChanged) actually binds to, matching setMonGainSb. NOT phoneStateChanged() (that drives syncCwFromModel, which never touches the MON button). No feedback loop: the sync wraps its body in m_updatingFromModel and the toggled handler early- returns on that flag, so the optimistic setChecked cannot re-emit the command. Principle II: the command path stays client->radio; the radio's sb_monitor status echo (parsed at TransmitModel.cpp:104-106) supersedes the optimistic value, with no feedback loop. Command/status split per Principle I (FlexLib: command "mon", status key "sb_monitor", Radio.cs:10615). Blast radius: risk_score=0.411, 23 high-risk affected (top: MainWindow::MainWindow, MainWindow::wireRadioModel, RadioModel:: troubleshootingSnapshot). All structural noise from class-level granularity; the change only adds an existing signal emit already wired and feedback- guarded, no signature/symbol/dependency change. Test-target link surface unchanged (no new external symbol or include). Note: cmake unavailable in this environment, so a full build was not run; the edit uses only pre-declared members/signals isomorphic to verified sibling setters in the same TU. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…inciple II. setCwSidetone() sent the `cw sidetone` command but never updated its local member m_cwSidetone, so cwSidetone() returned a stale value until the radio's status echo arrived. Apply the canonical guarded-optimistic pattern (identical to #3712 setAmCarrierLevel and the adjacent setCwBreakIn/setCwDelay/setCwIambic setters): update m_cwSidetone and emit phoneStateChanged() when the value changes, before sending the command. No feedback loop: PhoneCwApplet's button->model edge is guarded with !m_updatingFromModel and its phoneStateChanged sync wraps widget updates in m_updatingFromModel=true/false plus QSignalBlocker, so the optimistic emit cannot re-send the command. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TransmitModel::setDax sent the radio command but never wrote its local m_daxOn member, so a read in the same tick reflected the change only after the radio's dax= status echo (~80ms live). This mirrors the sibling mic setters (setSpeechProcessorEnable, setMicLevel, setMonGainSb) which update optimistically and emit micStateChanged(); setDax was the odd one out. Latent today because the radio echoes dax= promptly, but a slow or dropped echo would surface the same stale read as #3712. Emits micStateChanged() (not phoneStateChanged() from the issue body): the echo path sets m_daxOn under the micChanged flag and emits micStateChanged() (TransmitModel.cpp:232), and PhoneCwApplet syncs the DAX button in syncPhoneFromModel() (PhoneCwApplet.cpp:740), wired to micStateChanged (:667-668). The re-emit is safe: syncPhoneFromModel() sets m_updatingFromModel and wraps the button in QSignalBlocker, and the toggled handler is gated by !m_updatingFromModel (:313-315). commandReady stays unconditional, so callers that depend on the command always reaching the radio (Ax25HfPacketDecodeDialog save/restore, MainWindow shortcut/controller bindings) are unaffected; the optimistic read actually improves the daxOn()-then-set call sites. Blast radius: risk_score=0.128, 6 high-risk affected (top: MainWindow::MainWindow, Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog, MainWindow::registerShortcutActions). All real call sites verified non-regressing; high-risk entries are wiring/construction bridges that only instantiate TransmitModel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Fixes #3711
What was changed
fix(tx): setAmCarrierLevel caches m_amCarrierLevel optimistically (#3711). Principle II.
Files modified
src/models/TransmitModel.cppGenerated by AetherClaude (automated agent for AetherSDR)
🤖 aethersdr-agent · cost: $1.8398 · model: claude-opus-4-8