Skip to content

Adaptive RX filter: previous operator's width persists across a same-frequency QSO handoff (re-engage restore activated by #3945 m_fallbackClock) #4049

Description

@dsocha

Summary

On an SSB QSO — two operators alternating on the same dial frequency, one transmitting wider than the other (e.g. ~4 kHz and ~3.5 kHz) — the adaptive RX filter keeps the previous operator's passband width applied to the next operator. Operator-reported: "the 4 kHz filter is applied to the 3.5 kHz signal and vice versa," and sometimes the filter appears to stop adjusting even though the two widths are obviously different on the waterfall.

This is a regression that is live in main (shipped in #3945).

Root cause

The adaptive engine has a "re-engage restore" (lastGood* / restorable / kRefitMemoryNs) that re-applies the last confident fit when a station re-engages on the same frequency within 20 s. It was written to help a single station recovering from a deep QSB fade.

It was dormant as long as emittedNs == 0 (the timestamp was only stamped under perf-telemetry logging), because restorable requires emittedNs > 0. The #3945 review then added m_fallbackClock, which substitutes a monotonic timestamp so the wall-clock gates aren't inert — a correct fix in itself, but it makes emittedNs always positive, which activates the restore.

A QSO is precisely the case the restore's "same frequency within 20 s ⇒ same station" assumption gets wrong: both operators are on the same dial frequency, alternating within seconds. So A unkeys → lastGood = A's ~4 kHz fit is stamped → B keys up on the same frequency within 20 s → the restore re-applies A's 4 kHz to B. The narrowing back to B's real width is then further delayed by the fade-freeze (B's lower reference reads as a fade) and, on medium signals, by the low-SNR widen-only threshold.

Relevant code in src/core/AdaptiveFilterEngine.cpp (as merged in main):

  • m_fallbackClock substitution — makes emittedNs > 0.
  • lastGoodLow/High/FreqMhz/Ns save on confidence release.
  • restorable = atBaseline && lastGoodLow != INT_MIN && emittedNs > 0 && emittedNs - lastGoodNs <= kRefitMemoryNs (20 s) && |freq - lastGoodFreqMhz| <= 0.0003 → re-applies the remembered fit.

Expected

Each operator's passband should track its own occupied width within ~1–2 s of taking over, and hold across within-over word gaps.

Secondary (same area, worth fixing together)

  • Stops-adjusting: the low-SNR narrow-freeze constant (16 dB) sits above every presence preset (6/9/14), so a medium signal that engages can never narrow — the high-cut only ratchets wider.
  • F3 (bistable ESSB amputation): a single voice with a deep mid null + a treble hump can be cut as a "separate station" on a ~0.2 dB wiggle in the weak bass lobe.
  • F4 (weak-signal neighbour grab): an unconfident run can bridge to and grab an adjacent station 1.5–2 kHz above a weak carrier.

Notes

A fix is prepared (remove the restore; detect a genuine narrower-operator handoff via a sustained-step flush; scale the narrow-freeze to the preset; F3/F4 hysteresis) plus an engine-level regression test that drives processFrame with timestamps — the path no existing test covered, which is why this shipped. On-air validation on a real QSO is pending before a PR.

🤖 filed with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    audioAudio engine and streamingbugSomething isn't workingmaintainer-reviewRequires maintainer review before any action is takenpriority: mediumMedium priority

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions