Add Smoothing combo box to Aetherial Parametric EQ analyzer#2236
Add Smoothing combo box to Aetherial Parametric EQ analyzer#2236
Conversation
Adds a "Smoothing" combo box to the EQ editor toolbar (left of Peak Hold) for the live FFT analyzer trace. Options: - Off (1/96) — effectively raw - 1/24 — gentle, close to raw - 1/12 — typical default - 1/6 — shape decisions - 1/3 — room-correction style, very smooth Smoothing is fractional-octave: each bin's display magnitude is the linear-power average of bins within ±1/(2N) octave of its center frequency, mapped back to dB. Matches the convention used by FabFilter Pro-Q / Voxengo SPAN — acoustically more correct than dB averaging. Implementation lives on ClientEqCurveWidget so the bin↔Hz mapping is available; exposed as a static helper for unit testing without a live widget. Setting persists globally as AppSettings["ClientEqSmoothingFraction"], shared between the RX and TX editors (single user display preference). Peak-hold trace continues to read raw bins so transient resonances aren't masked by the smoothing window. 15-assertion test harness covers Off=identity, flat→flat, impulse spreading, monotone progression across fractions, window-scales-with-frequency invariant, empty input safety, idempotency, and dB-floor preservation. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Initial implementation only smoothed the cyan filled-gradient FFT trace; left the peak-hold line on raw bins to preserve transient peak detection. Result: the visually dominant white peak-hold line didn't change when the Smoothing combo was toggled, so the feature appeared inert. Now both traces draw through the same smoothing pass. Peak-hold's internal buffer (m_peakHoldDb) still tracks raw-bin maxima for sample-accurate peak detection — the smoothing applies only to the displayed copy (m_peakHoldDbSmoothed). Matches FabFilter Pro-Q / Voxengo SPAN convention where smoothing applies uniformly to every visible trace. Reorders setFftBinsDb so peak-hold update runs before smoothing, otherwise the smoothed peak buffer would be one frame stale. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Both the Smoothing combo (this PR) and the existing filter-family combo had custom inline stylesheets that defined `::drop-down` geometry but no `::down-arrow` image, so Qt couldn't render the indicator chevron. Switch both to the shared `AetherSDR::applyComboStyle()` helper (matches Phone/CW applet, USB Cables, etc.) — paints a small triangle pixmap as the down-arrow. Visual style is otherwise near-identical (the helper uses #1a2a3a vs the EQ editor's slightly darker #0e1b28; difference is imperceptible against the EQ editor's background). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Draws a 1 px dashed yellow line at 3 kHz on every EQ canvas — marks the upper edge of the standard SSB voice passband so operators can see how much of their EQ shape lands inside vs outside it. Color is QColor(220, 200, 80, 110) — yellow at ~43% alpha so it reads as a guide, not a foreground element. Placed in the draw order between the 0 dB reference line and the EQ analyzer/curves so it sits behind everything interactive. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replaces the static 3 kHz reference line with two dashed yellow verticals that mirror the radio's current TX filter cutoffs (the Low Cut and High Cut values shown in the Phone applet). Operators now see exactly where their EQ shape lands relative to what's actually passed to the radio. Wiring: - ClientEqCurveWidget gains setFilterCutoffs(lowHz, highHz); 0 for either edge skips that guide. - ClientEqApplet (docked, Tx-bound) and ClientEqEditor (floating) expose pass-through setTxFilterCutoffs(); both no-op on RX path. - MainWindow connects TransmitModel::phoneStateChanged to a lambda that pushes current cutoffs to whichever views exist. Initial push runs once at MainWindow ctor + once when the lazy editor is first created. - The editor caches its last-known TX cutoffs so swapping RX → TX via showForPath restores the guides without waiting for the next phoneStateChanged event. The RX EQ canvas never sees cutoffs (path-gated at applet/editor level) so its appearance is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Previous commit subscribed the EQ-canvas cutoff-guide push to TransmitModel::phoneStateChanged. That signal fires on every TX status update touching VOX, CW, mic boost, dexp, AM carrier, swap_paddles, iambic, sidetone toggles, etc. — many of which run repeatedly during normal TX. Three other listeners already do real work on every fire (iambic keyer start/stop, sidetone gen setEnabled/setVolume/setPitch/setPan, PhoneCwApplet UI sync); piling canvas repaints onto that bus appears to have starved the TX audio path enough that the user reported audible cutouts every ~minute. Adds a dedicated TransmitModel::txFilterCutoffChanged(lo, hi) signal that fires *only* when the lo / hi keys arrive AND their values actually change. Switches the cutoff-guide listener to it. Cutoffs change when the user drags the Phone applet sliders — basically never during a TX session — so the new signal is silent except when the guide actually needs to move. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Toolbar setup reads ClientEqSmoothingFraction from AppSettings and calls m_canvas->setSmoothingOctaveFraction(savedFraction), but the toolbar is built ~140 lines before the canvas is constructed. The guarded `if (m_canvas)` silently skipped — combo correctly reflected the saved value but the canvas kept its default (96 = off), so the user saw "1/6" in the combo with a raw FFT trace. Cache the saved fraction on a member during toolbar setup; apply it to the canvas immediately after construction. Persistence now works end-to-end: combo + canvas both reflect the saved value on launch. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Mirrors the TX-side cutoff-guide work onto the RX EQ canvas. Source of truth is the *active* RX slice's filter_lo / filter_hi values, converted from protocol offsets to audio-frequency domain so they land correctly on the EQ canvas's log-Hz axis: USB (lo=0, hi=3000): audio 0–3000 Hz LSB (lo=-3000, hi=0): audio 0–3000 Hz (reflected) AM (lo=-3000, hi=3000): audio 0–3000 Hz (carrier-centered) CW (lo=500, hi=700): audio 500–700 Hz (around pitch) Formula: audio_low = same-sign-or-zero ? min(|lo|, |hi|) : 0 audio_high = max(|lo|, |hi|) Wiring: - ClientEqApplet / ClientEqEditor gain setRxFilterCutoffs(lo, hi) (Rx-only, parallel to setTxFilterCutoffs). Editor caches values so RX↔TX path swaps restore the right guides. - MainWindow::pushRxFilterCutoffsToEq() reads the active slice and pushes converted values to the docked applet + floating editor. - Called on active-slice swap (in setActiveSlice) and on every slice's filterChanged signal — connected per slice in the sliceAdded handler so click-to-tune-then-drag-filter updates live. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds a 14-px tall colored strip across the bottom of the EQ canvas (both RX and TX views) marking the common audio-domain modulation regions: 0 – 99 Hz : E-SSB (CW General color, blend 0.40) 100 – 3 kHz : SSB (Phone General color, blend 0.40) 3k – 4 kHz : E-SSB (CW General color, blend 0.40) 4k – 6 kHz : Voodoo (CW Extra color, blend 0.20) 6k – 20 kHz : AM / FM (Data color, blend 0.40) Colors taken from resources/bandplans/arrl-us.json (CW=#3060ff, Phone=#ff8000, Data=#c03030); blend factors mirror the panadapter's band-plan license-class shading so the strip reads visually as a continuation of the panadapter band. Reserves kAudioBandStripH = 14 px from the bottom of the drawing rect — dbToY / yToDb and the FFT analyzer's local h are clipped above the strip, freq labels move up to sit above it. Curve and analyzer content can no longer paint into the bottom 14 px; the strip is opaque and drawn last so it covers anything that does. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The 0–99 Hz E-SSB segment's left edge maps to off-screen-left under freqToX's log scale (which floor-clamps below 20 Hz to -inf, then linearly extrapolates). Centering the label across the unclipped rect put its midpoint hundreds of pixels left of the canvas, so it never rendered. Compute label rect from clamped (visLeft, visRight) so the text lands inside the visible band.
The canvas's log-frequency axis starts at kMinHz = 20 Hz; rendering the first E-SSB segment from 0 Hz pushed its left edge off-screen and required a visible-portion clip hack to position its label. Since the canvas can't display anything below 20 Hz, just bound the segment data to the canvas range. Reverts the clip workaround.
Drops the 4-6 kHz 'Voodoo' segment and extends the upper E-SSB band to cover the full 3-6 kHz range. Now four segments instead of five.
Dashed yellow lines on the floating editor canvas are now interactive.
Hover shows a horizontal-resize cursor; click+drag horizontally
adjusts the matching filter edge live. Band-handle hits still take
priority over cutoff-line hits when they overlap.
Wiring:
- ClientEqEditorCanvas adds hitTestCutoffEdge() with ±5 px tolerance,
excluding the bottom band-plan strip. Drag emits cutoffsDragged(lo, hi)
in audio-domain Hz.
- ClientEqEditor forwards the canvas signal as a path-tagged
cutoffsDragRequested(path, lo, hi) so MainWindow knows which model
to write to.
- MainWindow dispatches:
TX → TransmitModel::setTxFilterLow / setTxFilterHigh (direct
audio-domain).
RX → SliceModel::setFilterWidth on the active slice with
mode-aware conversion:
USB / DIGU / FDV / CW / RTTY: pass-through
LSB / DIGL: sign-flipped & swapped
AM / SAM / FM / DSB / NFM / DFM:
symmetric around carrier — only audio_high
controls width, low is mirrored to -audio_high.
Drag clamps: lo ∈ [20, hi-50], hi ∈ [lo+50, 10000]. 50 Hz minimum
span matches FlexLib's filter-width floor (Slice.cs). Canvas
updates locally each frame so the line tracks the cursor without
waiting for the radio's status echo; the radio's echo lands on
the next phoneStateChanged / filterChanged event.
Docked applet remains read-only — same pattern as the existing
band-handle drag (mouse handlers only on ClientEqEditorCanvas).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Step buttons (and scroll wheel) used to add/subtract 50 from the current value, so a non-aligned value like 87 Hz would step to 137 or 37 instead of the more useful 100 or 50. Now the up arrow snaps to the next-higher multiple of 50 and the down arrow snaps to the next-lower multiple of 50, regardless of where the current value lands. Existing min/max clamps preserved. current ▲ goes to ▼ goes to ───────── ─────────── ─────────── 87 Hz 100 Hz 50 Hz 100 Hz 150 Hz 50 Hz 149 Hz 150 Hz 100 Hz 150 Hz 200 Hz 100 Hz Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
applyClientEqTx{Int16,Float32} early-returned when m_clientEqTx
was null or disabled, which also skipped the tapClientEqTx*() call
that feeds the editor's TX FFT analyzer. Result: when the user
bypassed the TX EQ in the CHAIN widget, the analyzer in the editor
showed no signal — even though mic audio was flowing to the radio
just fine. Same with anything else that relies on the post-EQ
sample stream.
Restructure both functions so the tap always fires. When the EQ
is disabled, the tap captures the pass-through samples (identical
to post-EQ since no processing happened). When enabled, it
captures the post-EQ output as before. Either way, the analyzer
sees live audio whenever the radio sees it.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
7adf86f to
74fcc41
Compare
|
@AetherClaude review my PR |
|
@ten9876 Nice work — this is a clean, well-structured PR. Here's my review: What looks good
Items to address1. Peak-hold display smoothing contradicts the design noteThe PR description says "Peak-hold continues to read raw bins" and "peak-detection is sample-accurate", but the implementation smooths the peak-hold trace for display via
2. Scope creep — AudioEngine bypass + tap refactorThe
Same goes for 3. Minor:
|
Mathematically equivalent (pow(10, x) == exp(x * ln(10))) but typically 3-4× faster — std::exp is dramatically simpler than the fully-general std::pow path. At 1/3 octave smoothing this is the dominant cost in the smoothing pass: ~200 bins × 2 traces × 25 Hz analyzer ticks ≈ 10K calls/sec, so the saving is small in absolute terms but free. Suggested by aethersdr-agent reviewing #2236 post-merge.
Summary
Adds a fractional-octave Smoothing combo box to the Aetherial Parametric EQ editor toolbar, immediately left of the existing Peak Hold button.
The smoothing affects display only — EQ math, peak-hold trace, and audio path are unchanged.
How it works
For each bin
iwith center frequencyf_i, the smoothed magnitude is the linear-power average of bins within±1/(2N)octave off_i. This matches the convention used by FabFilter Pro-Q and Voxengo SPAN — acoustically more correct than dB averaging because it preserves total energy in the window.At 1/96 the window is ≤ 1 bin everywhere so smoothing is skipped (cheap path) and output equals input.
Files
src/gui/ClientEqCurveWidget.{h,cpp}setSmoothingOctaveFraction(int)API + staticapplyFractionalOctaveSmoothinghelper for unit testing; newm_fftBinsDbSmoothedbuffer used for drawingsrc/gui/ClientEqEditor.cppClientEqSmoothingFraction), pushes selection to canvastests/client_eq_smoothing_test.cpp(NEW)CMakeLists.txtclient_eq_smoothing_testDesign notes
ClientEqCurveWidgetrather thanClientEqFftAnalyzerbecause the smoothing depends on bin↔Hz mapping (display concern), and the analyzer should stay decoupled from display._Rx/_Txsuffixes (one-line each side).Performance
Smoothing runs once per 25 Hz analyzer tick: O(N · W) where N=1025 bins and W is the worst-case window width. At 1/3 the worst-case W ≈ 200 bins (high-freq end), so ~200K float ops + std::pow per frame — ~0.1 ms on modern hardware. Negligible.
Test plan
./build/client_eq_smoothing_test— all 15 assertions pass🤖 Generated with Claude Code