feat(gui): add PEP peak-hold tick to TX FWDPWR gauge (#2561)#2596
Conversation
SmartSDR shows both a fast-moving needle and a slow peak-hold bar on the TX forward power gauge; AetherSDR's double-smoothed needle alone undershoots SSB PEP by 1-2 dB. Add a peak-hold tick that captures the raw pre-smoothed FWDPWR sample, holds for 2 s, then decays linearly toward the current smoothed reading at 50 W/s. - MeterModel: add txPeakChanged(float) signal carrying the existing m_fwdPowerInstant; emitted only on FWDPWR samples so SWR-only ticks don't re-fire. - TxApplet: new updatePeakPower / setTransmitting slots track the peak, run the hold + decay timer, and reset on un-key. Mirrors the SMeterWidget RX peak-hold pattern. - MainWindow: wire txPeakChanged and TransmitModel::moxChanged into TxApplet. Existing txMetersChanged signature is unchanged, so TciServer and the TX-mode S-meter path are untouched. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for tackling this — the implementation is small, focused, and the SMeterWidget peak-hold pattern is a good model to follow. The wiring (new txPeakChanged carrying the pre-smoothed sample, moxChanged → setTransmitting for un-key reset, HGauge::setPeakValue for rendering) all looks correct, and I verified the dependencies (HGauge::setPeakValue, TransmitModel::moxChanged, m_fwdPowerInstant) already exist on main.
A couple of things worth considering:
1. Decay rate doesn't scale with the gauge maximum (src/gui/TxApplet.cpp:73)
kDecayWattsPerSec = 50.0f was tuned for a 120 W barefoot scale ("empties in ~2 s"). On a 1500 W amp scale, a 1500 W peak will take ~30 s to bleed off after the 2 s hold — much longer than the operator-feedback "PEP tick" intent and noticeably out of step with the SMeterWidget ballistic (which is scale-invariant because it works in dB).
Suggestion: derive the decay from the gauge max so it always empties in ~2 s of decay regardless of scale, e.g.:
const float kDecayWattsPerSec = static_cast<HGauge*>(m_fwdGauge)->max() / 2.0f;(or capture the value set in setPowerScale into a member). Even just keying off m_powerScale would do it.
2. Minor: post-MOX FWDPWR straggler can re-arm the tick
updatePeakPower doesn't check a "transmitting" flag, so any FWDPWR sample that arrives between moxChanged(false) and the radio's final 0 W tick can momentarily raise the tick back above zero after the reset. Probably a 1-frame artifact in practice — but a if (!m_transmitting) return; guard in updatePeakPower (with m_transmitting tracked in setTransmitting) would make the un-key reset clean.
Both are minor — the PR does what #2561 asks for, the comments are great, and the data flow is solid. Nice work.
ten9876
left a comment
There was a problem hiding this comment.
Claude here, reviewing on Jeremy's behalf.
Verdict
Clean feature addition from AetherClaude. Correctly architected, uses existing infrastructure (HGauge::setPeakValue), matches SmartSDR's behavior. Recommend merge with one parameter question to think about.
Verified
- Correct architectural split.
MeterModelexposes the newtxPeakChanged(float fwdPowerInstant)signal carrying the pre-smoothed raw watts — this is the key insight, since the existing double-smoothing (model EMA + HGauge MeterSmoother) is exactly what's attenuating SSB peaks by 1-2 dB.TxAppletdecides display ballistics. - Mirrors existing patterns. Comment correctly references
SMeterWidget.cpp's RX peak-hold as prior art. UsesHGauge::setPeakValuefor the rendering — no reinvention. - Lifecycle correct. Timer starts on first peak, stops when decay completes or TX ends.
setTransmitting(false)resets to zero on un-key so a held peak doesn't linger across overs. - Thread safety fine.
MeterModelandTxAppletboth live on the main thread (per CLAUDE.md's "RadioModel owns all sub-models on the main thread"); direct connection is correct. - Stale-code audit: new signal/members all consistent, no orphans.
m_fwdPowerInstantalready existed in MeterModel — this PR just adds the emission gate. Backwards compatible — existingtxMetersChangedconsumers see no change. - Built locally clean (only pre-existing unrelated warnings). All 5 CI checks green. Auto-merges cleanly against current main.
One concern worth thinking about — decay rate scaling
The decay rate is hardcoded at kDecayWattsPerSec = 50.0f. Comment says "empties a barefoot (120 W) gauge in roughly 2 s." That's right for the typical 100 W/120 W setup, but doesn't scale for the extremes that AetherSDR supports:
| Setup | Max watts | 50 W/s decay behavior |
|---|---|---|
| QRP rig (5W max) | 5 W | Peak from 5 W → 0 in 0.1 s — tick effectively never visible |
| Standard 100 W | 120 W | ~2.4 s — matches comment, feels right |
| 500 W FLEX-8600 | 500 W | ~10 s — slightly long |
| PGXL amplifier | 1500 W | ~30 s — way too long; tick hangs around stale |
Suggested fix: scale the decay rate to the current power-scale max. setPowerScale(int maxWatts, ...) is already called when scale changes, and could set m_peakDecayWattsPerSec = maxWatts / 2.5f (gives ~2.5s decay from full-scale for any rig).
Not blocking — the issue author specifies 100W/120W scenarios and the current behavior matches SmartSDR there. But operators running PGXL will see a peak tick that hangs around longer than they expect. Worth either:
- Adding the scaling now (one-line change in
setPowerScale), or - Filing as a follow-up issue specifically for amplifier setups
Smaller observations (non-blocking)
if (fwdPowerInstant > m_peakPower)— correct peak-hold semantics. New samples only update the peak if they exceed the current (possibly decaying) value. Matches SmartSDR.- First-sample-of-TX as peak — if the very first sample after key-down is a glitch-high value, that gets held for 2 s. Matches SmartSDR's behavior per the issue description, so probably fine.
m_smoothedPowerdriver of decay terminus — when the decay reachesm_smoothedPower, the timer stops. Good — peak doesn't decay below the current display value, so the user always sees the tick at-or-above the bar.- No unit tests — defensible for visual UI features but a small unit test of the decay math (mock timer, feed samples, assert peak values at known time points) would be cheap. Not blocking.
Recommendation
Merge after Jeremy spot-tests on the FLEX-8600 with both:
- Barefoot SSB — speech peaks should show the tick rising rapidly, holding ~2 s, decaying back to the smoothed bar.
- High-power if available — verify decay feel at 500 W. If the 10-second decay seems too long, the scaling fix above is the right follow-up.
Thanks @aethersdr-agent — the architectural call to put the ballistics in TxApplet rather than MeterModel is correct (model exposes raw data, view decides presentation), and the pattern matches the existing SMeterWidget peak-hold. Clean implementation.
73, Jeremy KK7GWY & Claude (AI dev partner)
The constexpr 50 W/s decay rate was tuned for the barefoot 120 W gauge (~2.4 s peak-to-zero decay), but the same constant gives the wrong visual feel on the Aurora 500 W exciter gauge (0-600 W full scale), where 50 W/s stretches the decay to ~12 s and leaves the tick lingering long after the operator's voice peak has passed. Promotes the rate to a member `m_peakDecayWattsPerSec` and recomputes it in setPowerScale() against the gauge full-scale. Target is ~2.5 s from peak to floor regardless of rig class: Barefoot: 120 W / 2.5 s → 48 W/s (matches prior behavior) Aurora exciter: 600 W / 2.5 s → 240 W/s (was 50 W/s — 5x too slow) Default member value (48 W/s) matches barefoot for the pre-connect case before setPowerScale() runs. Co-Authored-By: aethersdr-agent <aethersdr-agent@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed `1193e8a7` with the decay-rate scaling fix discussed in the review. What changed`kDecayWattsPerSec = 50.0f` constexpr → `m_peakDecayWattsPerSec` member, recomputed in `setPowerScale()` against the gauge full-scale to give a consistent ~2.5 s peak-to-zero decay regardless of rig class:
The Aurora case was the visible bug — peak tick lingered ~10 seconds past the operator's actual peak. Now both rig classes feel the same. Default member value (48 W/s) matches the barefoot path so the pre-connect state — before any `setPowerScale()` call lands — has sensible behavior. Co-Authored-By tag preserves the original AetherClaude attribution. Build clean locally. CI will re-run on the push. 73, Jeremy KK7GWY & Claude (AI dev partner) |
ten9876
left a comment
There was a problem hiding this comment.
Claude here, follow-up review after the decay-scaling push.
Status
Pushed merge commit `7264afda` syncing in main (which now includes #2591 Profile Manager non-modal and `docs/dialog-patterns.md`). Clean auto-merge.
Final state of the PR
Two commits:
- `ddcefbf1` — AetherClaude's original PEP peak-hold tick implementation
- `1193e8a7` — my follow-up: scale the decay rate to gauge full-scale so the ~2.5 s visual feel stays consistent across barefoot (120 W) and Aurora exciter (600 W)
Plus the merge commit.
Post-merge audit — all main work preserved
| PR | Marker | Count |
|---|---|---|
| #2572 TxMicChannelNormalizer | `TxMicChannelNormalizer` in AudioEngine.cpp | 13 |
| #2583 AudioDeviceChangeDialog | `AudioDeviceChangeDialog`/`m_audioDeviceMonitor` | 5 |
| #2586 interlock notifications | `interlock`/`Interlock` in SpectrumWidget.cpp | 34 |
| #2591 ProfileManagerDialog non-modal | `m_profileManagerDialog` in MainWindow.cpp | 7 |
| This PR — PEP peak-hold | `updatePeakPower`/`m_peakDecayWattsPerSec` in TxApplet | 10 across .h+.cpp |
| This PR — MeterModel signal | `txPeakChanged` | 2 |
| `docs/dialog-patterns.md` (from #2591) | present | 10 KB |
Built locally clean (only pre-existing unrelated warnings). All 5 CI checks green on the pre-merge HEAD.
Architectural review unchanged
The feature is well-architected and the decay-scaling fix from my earlier push closes the only substantive concern from the original review:
- Model exposes raw data: `MeterModel::txPeakChanged(float fwdPowerInstant)` carries the pre-smoothed FWDPWR sample, not the double-smoothed value
- View owns presentation: `TxApplet` decides display ballistics (2 s hold + decay to floor), separated by concern
- Reuses existing infrastructure: `HGauge::setPeakValue` already renders the static tick; nothing reinvented
- Mirrors `SMeterWidget` RX peak-hold pattern: comment correctly references the prior art
- Lifecycle clean: `setTransmitting(false)` resets the peak to zero on un-key so a held PEP doesn't linger across overs
- Decay scales with gauge: `m_peakDecayWattsPerSec = gaugeFullScaleW / 2.5f` ensures consistent ~2.5 s decay visual feel across barefoot (48 W/s) and Aurora exciter (240 W/s)
- Threading correct: `MeterModel` and `TxApplet` both on the main thread; direct connection is right
Recommendation
Merge after Jeremy spot-tests on the FLEX-8600 with both:
- Barefoot SSB — speech peaks should show the tick rising rapidly, holding ~2 s, decaying back to the smoothed bar over ~2.5 s
- Aurora 500 W exciter (if available) — verify the new decay scaling feels right; peak shouldn't linger 10+ seconds
The decay-rate fix specifically addresses the Aurora case; the barefoot case behavior should be effectively identical to AetherClaude's original `50 W/s` (new value is `48 W/s` = barefoot-equivalent within rounding).
Thanks @aethersdr-agent for the original implementation — model→view split was the right call. 73, Jeremy KK7GWY & Claude (AI dev partner)
ten9876
left a comment
There was a problem hiding this comment.
Approved — PEP peak-hold with gauge-scaled decay.
Summary
Fixes #2561
What was changed
feat(gui): add PEP peak-hold tick to TX FWDPWR gauge (#2561)
Files modified
src/gui/MainWindow.cppsrc/gui/TxApplet.cppsrc/gui/TxApplet.hsrc/models/MeterModel.cppsrc/models/MeterModel.hGenerated by AetherClaude (automated agent for AetherSDR)