Skip to content

RN2 (RNNoise) toggle on Aetherial Tube Pre-Amp (TX mic input) #2813

Description

@ten9876

Plan: RN2 (RNNoise) toggle on Aetherial Tube Pre-Amp

Context

AetherSDR ships client-side RN2 (RNNoise) on the RX path today — wired
through AudioEngine::setRn2Enabled() and m_rn2, persisted as Rn2Enabled,
exposed in the RX DSP UI. The TX path has no equivalent: a noisy mic
environment (fan, traffic, kids, HF QRM bleed from the room) currently relies
entirely on the Gate stage to suppress it, which is a level-threshold tool,
not a neural denoiser.

This plan adds an RN2 toggle to the TX mic pre-amp area of the Aetherial
Audio Channel Strip — the StripTubePanel (window title "Aetherial Dynamic
Tube Pre-Amp"). Engineering rationale: RNNoise wants the rawest mic signal
possible, before any compression / EQ / saturation that would color the
noise floor. The Tube Pre-Amp panel is conceptually and visually the input
stage, which is why the toggle lives there rather than as a re-orderable
CHAIN-widget stage.

Architecture summary

Layer What's added
DSP New m_rn2Tx (std::unique_ptr<RNNoiseFilter>) + std::atomic<bool> m_rn2TxEnabled{false} in AudioEngine. Lock-free Q_INVOKABLE setter, atomic read in audio path.
TX audio path Hook in onTxAudioReady() immediately after TxMicChannelNormalizer::canonicalizeInt16ToMonoStereo() and before any resampling / RADE / DAX branch / DSP chain.
UI Shorten m_outMeter in StripTubePanel and place a small RN2 toggle button beneath it, inside the same column layout. Toggle is bright/dim (lit when active), single-click flip.
Persistence New AppSettings key. Nested-JSON shape (Principle V) under a AetherialTubePreampTx object so it can grow to hold future pre-amp options without further migration.
Signal routing StripTubePanel::rn2ToggleClicked() → emits rn2TxEnabledChanged(bool) → MainWindow forwards to m_audio->setRn2TxEnabled(). Pattern matches the existing CHAIN-stage toggle flow.

Files touched

New

  • (none — RNNoiseFilter already exists)

Modified

File Change
src/core/AudioEngine.h Add m_rn2Tx, m_rn2TxEnabled, public API setRn2TxEnabled(bool) / rn2TxEnabled() const.
src/core/AudioEngine.cpp prepare() allocates the filter. onTxAudioReady() runs the filter when the atomic is true. New loadAetherialTubePreampTxSettings() / saveAetherialTubePreampTxSettings() helpers.
src/gui/StripTubePanel.h Add QPushButton* m_rn2Btn. Signal rn2TxEnabledChanged(bool).
src/gui/StripTubePanel.cpp Shrink m_outMeter (override setMinimumHeight() for the in-strip instance only — see "Meter sizing" below). Add m_rn2Btn below the meter inside the same vertical layout. Wire to engine + persistence.
src/gui/AetherialAudioStrip.cpp Forward StripTubePanel::rn2TxEnabledChanged out as rn2TxEnabledChanged so MainWindow can observe (mirrors how stageEnabledChanged is bubbled up).
src/gui/MainWindow.cpp Connect AetherialAudioStrip::rn2TxEnabledChangedm_audio->setRn2TxEnabled(). Initial visibility seed on connect.
tests/audio_engine_tx_rn2_test.cpp (new) Smoke test: enable RN2 TX, push impulse mic block, confirm filter ran (output non-zero, different from input). Disable, confirm passthrough.
CMakeLists.txt Wire the new test target.

Detailed design

1. DSP — AudioEngine

AudioEngine.h additions (in the "Client-side RN2 (RNNoise)" block, line 687-689 region — kept tightly grouped with the RX-side RN2 fields so reviewers see both at once):

// Client-side RN2 — TX path (mic pre-amp).
// Separate instance from m_rn2 (RX) so RX and TX can be toggled
// independently and the filter state is per-direction (#NNNN).
std::unique_ptr<RNNoiseFilter> m_rn2Tx;
std::atomic<bool> m_rn2TxEnabled{false};

Public API additions (next to existing RX RN2 surface, line 164-166):

// Client-side RN2 — TX path (mic pre-amp).
// Q_INVOKABLE: called from main thread, runs on audio worker thread.
Q_INVOKABLE void setRn2TxEnabled(bool on);
bool rn2TxEnabled() const { return m_rn2TxEnabled.load(); }

AudioEngine.cppprepare() allocation:

m_rn2Tx = std::make_unique<RNNoiseFilter>();
// RNNoiseFilter ctor is alloc-only; no per-block heap traffic.

setRn2TxEnabled():

void AudioEngine::setRn2TxEnabled(bool on)
{
    const bool prev = m_rn2TxEnabled.exchange(on);
    if (prev == on) return;

    // On transition, reset the filter state so the next block doesn't
    // carry a stale tail from a previous TX session.
    if (m_rn2Tx) m_rn2Tx->reset();

    // Persist immediately — Channel Strip toggles save on click.
    saveAetherialTubePreampTxSettings();

    qCInfo(lcAudio).noquote() << "AudioEngine: RN2 TX" << (on ? "enabled" : "disabled");
}

2. TX audio path — onTxAudioReady() hook

Hook location — RN2 lives strictly on the voice path. Per maintainer
guidance, noise reduction must never apply to digital modes (RADE, DAX/TCI,
RTTY, FT8, DIGU, DIGL, FDV, CW). The existing onTxAudioReady() flow
already gates the voice DSP chain on this distinction: it early-returns
before applyClientTxDspInt16() when m_radeMode or m_daxTxMode is set.
RN2 must use the same gate.

Concretely, the hook goes after both early-returns, in the same block
as applyClientTxDspInt16(), and before any DSP chain stage so RN2
denoises raw mic before any gate / comp / EQ / saturation can amplify the
noise floor:

// (after canonicalize + optional resample)

if (m_radeMode) { /**/ return; }     // RADE early-return
if (m_daxTxMode) return;                // DAX early-return

// ── Voice-path RN2 (mic pre-amp neural denoiser) ────────────
// Runs BEFORE the user's DSP chain so any gate / comp / EQ / saturator
// processes denoised audio rather than amplifying the noise floor.
// Inherits the RADE/DAX gate via the early-returns above, so RN2 is
// guaranteed never to touch digital-mode TX paths.
if (m_rn2TxEnabled.load() && m_rn2Tx && m_rn2Tx->isValid()) {
    data = m_rn2Tx->process(data);
}

// (test tone, applyClientTxDspInt16, PUDU monitor tap, …)

Format requirement. RNNoiseFilter::process() expects 24 kHz
duplicated-stereo int16 (per its header contract). The hook fires AFTER
the m_txNeedsResample block (around line 3950 in current code), so
data at that point is canonical 24 kHz duplicated-stereo int16 — exactly
what RN2 needs.

Thread safety. Atomic read on audio thread, atomic write on main
thread. RNNoiseFilter is single-threaded; the audio thread is the sole
caller of process() and reset() (the latter is called from
setRn2TxEnabled() on toggle changes — see the threading note in the
"Implementation" section below for the brief torn-state window and why
it's acceptable).

3. UI — StripTubePanel meter sizing + toggle (TX-only)

StripTubePanel is dual-side. The same class is instantiated as
m_tube (TX) and m_tubeRx (RX) in AetherialAudioStrip.cpp:448, 547.
The side is set via showForTx() / showForRx() post-construction
(StripTubePanel.cpp:319, 336). The RX path already has its own RN2
toggle elsewhere (AetherDspWidget / ClientRxChainWidget /
StripRxChainWidget), so adding RN2 here on the RX instance would create
a duplicate RX control.

Side-aware visibility. Create the toggle in the ctor (so the widget
exists on both instances) but flip its visibility — and the meter shrink
— inside showForTx() / showForRx():

// In ctor: create as hidden, only show when this instance is on the TX side.
m_rn2Btn = new QPushButton("RN2", this);
m_rn2Btn->setCheckable(true);
m_rn2Btn->setFixedHeight(16);
m_rn2Btn->setVisible(false);   // default — flipped by showForTx()

// In showForTx():
m_rn2Btn->setVisible(true);
m_outMeter->setMinimumHeight(140);
m_outMeter->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

// In showForRx():
m_rn2Btn->setVisible(false);
m_outMeter->setMinimumHeight(160);  // restore full-height
m_outMeter->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);

This way the standalone ClientTubeEditor (which uses a separate code
path) stays unchanged AND the strip's RX-side Tube panel is unchanged
AND only the strip's TX-side Tube panel gets the shortened meter + RN2
button.

Meter shrink rationale. Default ClientLevelMeter is 160 px tall.
140 px keeps the dB scale ticks and label header legible while leaving
~20 px (16 px button + 4 px gap) for the RN2 toggle within the original
panel envelope.

Toggle button. A small bright-when-active toggle, 16 px tall, label
"RN2", visual idiom matching the existing CHAIN-stage tiles (amber-yellow
when active, dim grey when bypassed):

m_rn2Btn = new QPushButton("RN2", this);
m_rn2Btn->setCheckable(true);
m_rn2Btn->setFixedHeight(16);
m_rn2Btn->setToolTip(
    tr("Toggle RNNoise neural denoiser on the mic input.  Runs before "
       "any DSP chain stage so noise is suppressed before it can be "
       "amplified by gate / compressor / saturator."));
m_rn2Btn->setStyleSheet(
    "QPushButton {"
    "  background: #0e1b28;"
    "  color: #506070;"
    "  border: 1px solid #243a4e;"
    "  border-radius: 3px;"
    "  font-size: 10px; font-weight: bold;"
    "  padding: 0 4px;"
    "}"
    "QPushButton:hover { background: #1a2a3a; color: #8aa8c0; }"
    "QPushButton:checked {"
    "  background: #3a2a0e; color: #f2c14e;"
    "  border: 1px solid #f2c14e;"
    "}"
    "QPushButton:checked:hover { background: #4a3818; }");
connect(m_rn2Btn, &QPushButton::toggled, this, [this](bool on) {
    if (m_audio) m_audio->setRn2TxEnabled(on);
    // No need to also emit + connect through MainWindow — the direct
    // engine call is the single source of truth.  Cross-widget
    // observers (if any) connect to AudioEngine::rn2TxEnabledChanged
    // (emitted by the setter), not to this widget's signal.
});

Layout placement. After body->addWidget(m_outMeter), the meter's
column is currently a single widget. Wrap into a vertical sub-layout so
the toggle sits flush against the meter's bottom edge:

auto* meterCol = new QVBoxLayout;
meterCol->setContentsMargins(0, 0, 0, 0);
meterCol->setSpacing(4);
meterCol->addWidget(m_outMeter);
meterCol->addWidget(m_rn2Btn, 0, Qt::AlignHCenter);
body->addLayout(meterCol);

Replaces the prior body->addWidget(m_outMeter).

4. Persistence — nested-JSON

Per Principle V (nested JSON under one key), the persistence shape leaves
room for future Tube-Pre-Amp toggles (e.g. high-pass filter, phase invert)
without further migration:

// AppSettings key: AetherialTubePreampTx
// Value:           JSON object
// Shape today:
//   {"rn2": false}
// Future shape (illustrative):
//   {"rn2": true, "highpass_hz": 80, "phase_invert": false}

AudioEngine::loadAetherialTubePreampTxSettings():

void AudioEngine::loadAetherialTubePreampTxSettings()
{
    auto& s = AppSettings::instance();
    const QString raw = s.value("AetherialTubePreampTx", "{}").toString();
    QJsonParseError err;
    const auto doc = QJsonDocument::fromJson(raw.toUtf8(), &err);
    if (err.error != QJsonParseError::NoError || !doc.isObject()) {
        m_rn2TxEnabled.store(false);
        return;
    }
    const auto obj = doc.object();
    m_rn2TxEnabled.store(obj.value("rn2").toBool(false));
}

saveAetherialTubePreampTxSettings() mirrors with a fresh QJsonObject ↦
toJson(QJsonDocument::Compact) ↦ AppSettings setValue + save.

Both helpers are called: load on AudioEngine construction (after other
load helpers), save inside setRn2TxEnabled() on every change.

Migration: None. This is a new key — defaults to off for everyone.
Existing users get AetherialTubePreampTx = "{}" on first save, parsed
to m_rn2TxEnabled = false.

5. Signal routing

Single-path wiring. The StripTubePanel toggle calls
m_audio->setRn2TxEnabled() directly — no signal-forwarding through
AetherialAudioStrip and MainWindow. The AudioEngine::setRn2TxEnabled()
setter emits rn2TxEnabledChanged(bool) for any cross-widget observers
(none today, but the signal is there for future use).

This deliberately differs from the CHAIN-stage stageEnabledChanged
pattern (which DOES bubble through AetherialAudioStrip to keep the
docked Chain applet in sync). RN2 doesn't have a duplicate UI surface
that needs to mirror; if a future feature adds one, it can subscribe
to the engine's signal directly.

Initial state seed. When StripTubePanel is constructed, seed the
toggle from m_audio->rn2TxEnabled() so a strip popped open after the
user has already enabled RN2 (via preset import or settings restore)
opens showing the correct state:

if (m_audio) {
    QSignalBlocker block(m_rn2Btn);
    m_rn2Btn->setChecked(m_audio->rn2TxEnabled());
}

Done in the constructor right after the button is added to the layout.

6. Allocation pattern — align with existing RX RN2

The existing RX RN2 setter (AudioEngine::setRn2Enabled(),
AudioEngine.cpp:3244-3270) lazily allocates the RNNoiseFilter
on toggle-on, holds m_dspMutex during allocation, and frees it on
toggle-off. The TX setter should follow the same pattern for
consistency:

void AudioEngine::setRn2TxEnabled(bool on)
{
    {
        std::lock_guard<std::mutex> lock(m_dspMutex);
        if (on && !m_rn2Tx) {
            m_rn2Tx = std::make_unique<RNNoiseFilter>();
        }
    }
    const bool prev = m_rn2TxEnabled.exchange(on);
    if (prev == on) return;

    saveAetherialTubePreampTxSettings();
    emit rn2TxEnabledChanged(on);
    qCInfo(lcAudio).noquote() << "AudioEngine: RN2 TX" << (on ? "enabled" : "disabled");
}

The audio-thread read at the call site uses the atomic boolean as the
guard, then reads m_rn2Tx via raw pointer load — same pattern RX RN2
uses today. The mutex protects only the allocation/deallocation; the
audio thread never blocks on it.

Tests

tests/audio_engine_tx_rn2_test.cpp (new):

  1. Disabled default — fresh AudioEngine has rn2TxEnabled() == false.
  2. Toggle persistence — setRn2TxEnabled(true) → save → fresh AudioEngine
    loads with rn2TxEnabled() == true. Save shape is JSON.
  3. Filter passthrough when disabled — feed an impulse mic block, output
    equals input.
  4. Filter active when enabled — feed the same impulse, output differs
    from input (denoised). No exact equality check — RNNoise behavior is
    model-dependent — but L2 distance > 0.
  5. State reset on toggle — enable, push noisy block, disable, enable
    again, push silent block, output should be silent (no residual tail
    from the previous active session).
  6. Lock-free smoke — set rn2TxEnabled from a worker thread while
    another thread reads it; no torn reads (TSan or ThreadSanitizer-style
    sanity).
  7. Steady-state allocation — push 100+ mic blocks with RN2 enabled
    and verify no heap allocations occur after block Implement XVTR band sub-menu #5 (heaptrack /
    allocation counter check). RNNoise's internal scratch buffers
    should reach steady size within a few blocks; if m_inAccum /
    m_outAccum keep growing, that's a leak we want to catch here.

Verification (manual)

  1. Open Channel Strip TX, observe the Tube Pre-Amp panel — output meter
    should still be visible, but ~20 px shorter than current, with a small
    "RN2" toggle directly beneath it.
  2. Toggle RN2 off (default) — TX a normal voice signal — confirm passthrough.
  3. Toggle RN2 on — TX with fan/keyboard/HVAC noise audible in the room —
    confirm WSJT-X / FT8 / SSB receiver hears reduced noise floor.
  4. Close + reopen AetherSDR — RN2 state persists.
  5. Open ~/.config/AetherSDR/AetherSDR.settings — confirm
    AetherialTubePreampTx key exists, JSON value contains "rn2": true.
  6. Open ClientTubeEditor (the floating-window variant of the panel) —
    confirm its output meter is still full-height (the in-strip shrink is
    scoped to StripTubePanel, not the standalone editor).

Threading / lock-free pattern

  • m_rn2TxEnabled is a std::atomic<bool> — main-thread write, audio-thread read. No mutex in audio callback.
  • m_rn2Tx lifetime: allocated lazily under m_dspMutex inside setRn2TxEnabled(true), freed under the same mutex inside setRn2TxEnabled(false). Matches the RX RN2 pattern.
  • RNNoiseFilter::process() is internally stateful but single-threaded; the audio thread is the sole caller.
  • RNNoiseFilter::reset() is no longer called on toggle (we destroy/recreate instead), so the torn-reset edge case doesn't apply.

Mutual exclusion with other TX-side NR

The existing RX setRn2Enabled() (AudioEngine.cpp:3098, 3151, 3207, 3281, 3464) disables NR2 / NR4 / BNR / DFNR / MNR when RN2 is enabled, because those are competing RX-side noise reductions and stacking them is destructive.

TX side: no mutual exclusion needed. AetherSDR currently has no other TX-side neural noise reduction (NR2/NR4/BNR/DFNR/MNR are RX-only). RN2 TX is the only neural denoiser on the mic path, so the setter doesn't need to disable anything. If a future feature adds, say, NR4 on TX, the mutual-exclusion logic gets added then.

Constitution alignment

  • Principle V (nested JSON under one key)AetherialTubePreampTx is the single key, nested object inside. Mirrors the ClientCompTxChain / ClientEqTxChain shape that newer features follow.
  • Principle VI (CHAIN-widget TX DSP entry) — RN2 here is NOT a chain stage. It's pre-chain input conditioning, conceptually parallel to mic gain or phase invert. This is the right architectural placement for noise reduction (denoise raw signal before any processing), and respects the principle by NOT cluttering the CHAIN widget with non-reorderable input conditioning.
  • MeterSmoother canonicality — N/A; this PR doesn't add meters, only shortens an existing one's vertical extent.

Out of scope (deferred)

  • RX mirror — RNNoise (RN2) on the RX is already wired and exposed in the RX DSP applet today. This PR doesn't touch the RX side or unify the two toggles.
  • Mic-preamp section expansion — the nested-JSON persistence anticipates further mic-preamp toggles (high-pass, phase invert, polarity), but those are not implemented here.
  • NR4 / DFNR on TX — could be added later as alternative denoisers in the same mic-preamp section. Same persistence pattern would extend; new keys inside the JSON object.
  • Per-band RN2 enable state — RN2 TX is a single global toggle today, not per-band like some compressor settings. If operators want per-band, that's a follow-up.

Risks

  • CPU cost — RNNoise on a 24 kHz mono signal is sub-1% CPU on a modern x86_64. On Raspberry Pi 5 it's ~3-5%. No measurable impact on TX latency.
  • Latency — RNNoise introduces ~10 ms internal latency (480-sample frames at 48 kHz). Total TX path latency increases by that amount when RN2 is enabled. WSJT-X / FT8 / SSB are tolerant; CW operators may notice if they enable RN2 during CW (toggle is off by default and not on the CW signal path anyway).
  • Voice quality on speech with low SNR — RNNoise can introduce artifacts ("musical noise") on speech recorded in very low-SNR environments. Operator-controlled toggle so they can A/B and choose.
  • First-time UX — toggle is off by default, so no behavior change for existing users. Discovery: tooltip + documentation in the next User Guide pass.
  • Preset interaction — current Channel Strip presets don't include the mic-preamp state. Presets that save RN2 state will be a future preset-schema bump. Until then, RN2 toggle is "outside" the preset system and persists independently.

Implementation order

  1. AudioEngine surface (header + cpp setters/getters + load/save helpers) — pure plumbing, no UI.
  2. prepare() allocation + onTxAudioReady() hook — RN2 functional but no toggle yet (defaults off).
  3. Tests — confirm load/save/passthrough/active.
  4. StripTubePanel meter shrink + RN2 button + signal — UI lights up.
  5. AetherialAudioStrip forward signal + MainWindow connect — wire-through.
  6. Initial-state seed + verification pass — confirm reopen state.
  7. Manual TX test with mic noise — confirm denoising is audible.

Estimated size

~150-200 LOC of source + ~80 LOC of test. 3-4 hours implementation, 1-2 hours testing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GUIUser interfaceaudioAudio engine and streamingenhancementImprovement to existing featuremaintainer-reviewRequires maintainer review before any action is taken

    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