Skip to content

Add Smoothing combo box to Aetherial Parametric EQ analyzer#2236

Merged
ten9876 merged 16 commits intomainfrom
feature/eq-fractional-octave-smoothing
May 1, 2026
Merged

Add Smoothing combo box to Aetherial Parametric EQ analyzer#2236
ten9876 merged 16 commits intomainfrom
feature/eq-fractional-octave-smoothing

Conversation

@ten9876
Copy link
Copy Markdown
Owner

@ten9876 ten9876 commented May 1, 2026

Summary

Adds a fractional-octave Smoothing combo box to the Aetherial Parametric EQ editor toolbar, immediately left of the existing Peak Hold button.

Value Use case
Off (1/96) Raw FFT trace, max detail (default)
1/24 Gentle, close to raw
1/12 Typical analyzer default
1/6 EQ-shape decisions
1/3 Room-correction style, very smooth

The smoothing affects display only — EQ math, peak-hold trace, and audio path are unchanged.

How it works

For each bin i with center frequency f_i, the smoothed magnitude is the linear-power average of bins within ±1/(2N) octave of f_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.

// Window in octaves: ±1/(2N)
const double mulHi = std::exp2( 1.0 / (2.0 * N));
const double mulLo = std::exp2(-1.0 / (2.0 * N));
// For bin i at freq fc, average over bins covering [fc·mulLo, fc·mulHi]

At 1/96 the window is ≤ 1 bin everywhere so smoothing is skipped (cheap path) and output equals input.

Files

File Δ
src/gui/ClientEqCurveWidget.{h,cpp} New setSmoothingOctaveFraction(int) API + static applyFractionalOctaveSmoothing helper for unit testing; new m_fftBinsDbSmoothed buffer used for drawing
src/gui/ClientEqEditor.cpp New combo box in the toolbar, AppSettings load/save (ClientEqSmoothingFraction), pushes selection to canvas
tests/client_eq_smoothing_test.cpp (NEW) 15-assertion harness — operates on the static helper, no QApplication required
CMakeLists.txt Register client_eq_smoothing_test

Design notes

  • Smoothing lives on ClientEqCurveWidget rather than ClientEqFftAnalyzer because the smoothing depends on bin↔Hz mapping (display concern), and the analyzer should stay decoupled from display.
  • Peak-hold continues to read raw bins — the peak-hold trace's job is to surface transient resonances, smoothing it would defeat the point. Documented in the analyzer-pass comment.
  • Setting is shared between RX and TX editors — single user display preference. If split control is desired later, just add _Rx/_Tx suffixes (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 clean on Linux
  • ./build/client_eq_smoothing_test — all 15 assertions pass
  • CI green
  • Visual: open EQ editor, cycle through smoothing values, observe progressive smoothing of the analyzer trace
  • Visual: Peak Hold trace stays sharp at high smoothing values (correct behavior)
  • Setting persists across editor close/reopen
  • Selection in RX editor reflected in TX editor (shared setting)

🤖 Generated with Claude Code

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]>
ten9876 and others added 15 commits April 30, 2026 23:02
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]>
@ten9876 ten9876 force-pushed the feature/eq-fractional-octave-smoothing branch from 7adf86f to 74fcc41 Compare May 1, 2026 07:54
@ten9876 ten9876 marked this pull request as draft May 1, 2026 07:57
@ten9876 ten9876 marked this pull request as ready for review May 1, 2026 08:02
@ten9876 ten9876 merged commit 067b054 into main May 1, 2026
8 checks passed
@ten9876 ten9876 deleted the feature/eq-fractional-octave-smoothing branch May 1, 2026 08:10
@ten9876
Copy link
Copy Markdown
Owner Author

ten9876 commented May 1, 2026

@AetherClaude review my PR

@aethersdr-agent
Copy link
Copy Markdown
Contributor

@ten9876 Nice work — this is a clean, well-structured PR. Here's my review:

What looks good

  • Smoothing algorithm is correct. Linear-power averaging (10^(dB/10)) rather than dB averaging is the right choice — matches Pro-Q/SPAN and preserves energy. The 1e-12 epsilon in log10 prevents -inf on silent bins. Tests validate this thoroughly.
  • Architecture decision is sound. Keeping smoothing on ClientEqCurveWidget rather than ClientEqFftAnalyzer is the right call — it's a display concern tied to bin↔Hz mapping.
  • Test harness is solid. 15 assertions covering edge cases (empty input, floor preservation, idempotency, frequency-dependent window scaling). Good that it exercises the static helper without needing QApplication.
  • Peak-hold tracks raw bins. Correct — smoothing the max-tracking would mask the transient resonances that peak-hold exists to surface.

Items to address

1. Peak-hold display smoothing contradicts the design note

The 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 m_peakHoldDbSmoothed. The code comment at line 193 explains the rationale (visual consistency), but this contradicts what the description promises. I'd recommend either:

  • Updating the PR description to reflect the actual behavior, or
  • Drawing the peak-hold trace from raw m_peakHoldDb if the intent really is to keep it sharp for spotting resonances (which I think is the stronger UX choice — a sharp peak-hold against a smooth FFT fill is how most pro analyzers work)

2. Scope creep — AudioEngine bypass + tap refactor

The AudioEngine::applyClientEqTxInt16 / applyClientEqTxFloat32 changes restructure the EQ bypass logic so the FFT tap always runs even when EQ is disabled. This is a behavioral change to the audio pipeline that's unrelated to the smoothing feature. I'd split this into its own commit or PR:

  • It changes the semantics of these functions (they now always tap, previously they early-returned)
  • It touches the audio engine hot path where subtle regressions are hard to catch visually
  • It deserves its own test coverage

Same goes for ClientEqApplet::setFilterCutoffs and the audio band-plan strip — those are useful features but they expand the review surface of what should be a focused smoothing PR.

3. Minor: std::pow in the hot loop

sumLin += std::pow(10.0, static_cast<double>(binsDb[j]) / 10.0);

At 1/3 smoothing with W≈200 bins at the high end, this is ~200K std::pow calls per frame. You note it's ~0.1ms which is fine, but you could get a ~3-4x speedup with a lookup table or by using std::exp with a pre-multiplied ln(10)/10 constant:

constexpr double kLn10Over10 = 0.23025850929940457;
sumLin += std::exp(static_cast<double>(binsDb[j]) * kLn10Over10);

Not blocking, but a cheap win if you're already in here.

4. Minor: combo box findData fallback

const int savedIdx = smoothingCombo->findData(savedFraction);
smoothingCombo->setCurrentIndex(savedIdx >= 0 ? savedIdx : 0);

If a user hand-edits the config to a bogus value (e.g. 7), findData returns -1, index falls back to 0 ("Off"), but m_savedSmoothingFraction still holds 7 and gets pushed to the canvas at line 255. The canvas handles it gracefully (the >= 96 guard treats unknown values as off), but it would be cleaner to also clamp m_savedSmoothingFraction to 96 in the fallback path so the saved value and the combo selection agree.

Verdict

The smoothing core is solid and well-tested. I'd want the AudioEngine/filter-cutoff/band-plan changes split out before merging so each feature gets proper review coverage. The peak-hold display question (item 1) is worth a quick decision before it ships.

Good PR overall 👍

ten9876 added a commit that referenced this pull request May 1, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant