You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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::rn2TxEnabledChanged → m_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_INVOKABLEvoidsetRn2TxEnabled(bool on);
boolrn2TxEnabled() const { return m_rn2TxEnabled.load(); }
AudioEngine.cpp — prepare() allocation:
m_rn2Tx = std::make_unique<RNNoiseFilter>();
// RNNoiseFilter ctor is alloc-only; no per-block heap traffic.
setRn2TxEnabled():
voidAudioEngine::setRn2TxEnabled(bool on)
{
constbool 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-returnif (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:
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:
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:
voidAudioEngine::setRn2TxEnabled(bool on)
{
{
std::lock_guard<std::mutex> lock(m_dspMutex);
if (on && !m_rn2Tx) {
m_rn2Tx = std::make_unique<RNNoiseFilter>();
}
}
constbool 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):
Disabled default — fresh AudioEngine has rn2TxEnabled() == false.
Toggle persistence — setRn2TxEnabled(true) → save → fresh AudioEngine
loads with rn2TxEnabled() == true. Save shape is JSON.
Filter passthrough when disabled — feed an impulse mic block, output
equals input.
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.
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).
Lock-free smoke — set rn2TxEnabled from a worker thread while
another thread reads it; no torn reads (TSan or ThreadSanitizer-style
sanity).
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)
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.
Toggle RN2 off (default) — TX a normal voice signal — confirm passthrough.
Toggle RN2 on — TX with fan/keyboard/HVAC noise audible in the room —
confirm WSJT-X / FT8 / SSB receiver hears reduced noise floor.
Close + reopen AetherSDR — RN2 state persists.
Open ~/.config/AetherSDR/AetherSDR.settings — confirm AetherialTubePreampTx key exists, JSON value contains "rn2": true.
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
AudioEngine surface (header + cpp setters/getters + load/save helpers) — pure plumbing, no UI.
prepare() allocation + onTxAudioReady() hook — RN2 functional but no toggle yet (defaults off).
Tests — confirm load/save/passthrough/active.
StripTubePanel meter shrink + RN2 button + signal — UI lights up.
AetherialAudioStrip forward signal + MainWindow connect — wire-through.
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()andm_rn2, persisted asRn2Enabled,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 DynamicTube 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
m_rn2Tx(std::unique_ptr<RNNoiseFilter>) +std::atomic<bool> m_rn2TxEnabled{false}in AudioEngine. Lock-free Q_INVOKABLE setter, atomic read in audio path.onTxAudioReady()immediately afterTxMicChannelNormalizer::canonicalizeInt16ToMonoStereo()and before any resampling / RADE / DAX branch / DSP chain.m_outMeterinStripTubePaneland place a small RN2 toggle button beneath it, inside the same column layout. Toggle is bright/dim (lit when active), single-click flip.AppSettingskey. Nested-JSON shape (Principle V) under aAetherialTubePreampTxobject so it can grow to hold future pre-amp options without further migration.StripTubePanel::rn2ToggleClicked()→ emitsrn2TxEnabledChanged(bool)→ MainWindow forwards tom_audio->setRn2TxEnabled(). Pattern matches the existing CHAIN-stage toggle flow.Files touched
New
Modified
src/core/AudioEngine.hm_rn2Tx,m_rn2TxEnabled, public APIsetRn2TxEnabled(bool)/rn2TxEnabled() const.src/core/AudioEngine.cppprepare()allocates the filter.onTxAudioReady()runs the filter when the atomic is true. NewloadAetherialTubePreampTxSettings()/saveAetherialTubePreampTxSettings()helpers.src/gui/StripTubePanel.hQPushButton* m_rn2Btn. Signalrn2TxEnabledChanged(bool).src/gui/StripTubePanel.cppm_outMeter(overridesetMinimumHeight()for the in-strip instance only — see "Meter sizing" below). Addm_rn2Btnbelow the meter inside the same vertical layout. Wire to engine + persistence.src/gui/AetherialAudioStrip.cppStripTubePanel::rn2TxEnabledChangedout asrn2TxEnabledChangedso MainWindow can observe (mirrors howstageEnabledChangedis bubbled up).src/gui/MainWindow.cppAetherialAudioStrip::rn2TxEnabledChanged→m_audio->setRn2TxEnabled(). Initial visibility seed on connect.tests/audio_engine_tx_rn2_test.cpp(new)CMakeLists.txtDetailed design
1. DSP — AudioEngine
AudioEngine.hadditions (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):Public API additions (next to existing RX RN2 surface, line 164-166):
AudioEngine.cpp—prepare()allocation:m_rn2Tx = std::make_unique<RNNoiseFilter>(); // RNNoiseFilter ctor is alloc-only; no per-block heap traffic.setRn2TxEnabled():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()flowalready gates the voice DSP chain on this distinction: it early-returns
before
applyClientTxDspInt16()whenm_radeModeorm_daxTxModeis 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 RN2denoises raw mic before any gate / comp / EQ / saturation can amplify the
noise floor:
Format requirement.
RNNoiseFilter::process()expects 24 kHzduplicated-stereo int16 (per its header contract). The hook fires AFTER
the
m_txNeedsResampleblock (around line 3950 in current code), sodataat that point is canonical 24 kHz duplicated-stereo int16 — exactlywhat RN2 needs.
Thread safety. Atomic read on audio thread, atomic write on main
thread.
RNNoiseFilteris single-threaded; the audio thread is the solecaller of
process()andreset()(the latter is called fromsetRn2TxEnabled()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)
StripTubePanelis dual-side. The same class is instantiated asm_tube(TX) andm_tubeRx(RX) inAetherialAudioStrip.cpp:448, 547.The side is set via
showForTx()/showForRx()post-construction(
StripTubePanel.cpp:319, 336). The RX path already has its own RN2toggle elsewhere (
AetherDspWidget/ClientRxChainWidget/StripRxChainWidget), so adding RN2 here on the RX instance would createa 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():This way the standalone
ClientTubeEditor(which uses a separate codepath) 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
ClientLevelMeteris 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):
Layout placement. After
body->addWidget(m_outMeter), the meter'scolumn is currently a single widget. Wrap into a vertical sub-layout so
the toggle sits flush against the meter's bottom edge:
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:
AudioEngine::loadAetherialTubePreampTxSettings():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, parsedto
m_rn2TxEnabled = false.5. Signal routing
Single-path wiring. The
StripTubePaneltoggle callsm_audio->setRn2TxEnabled()directly — no signal-forwarding throughAetherialAudioStripandMainWindow. TheAudioEngine::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
stageEnabledChangedpattern (which DOES bubble through
AetherialAudioStripto keep thedocked 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
StripTubePanelis constructed, seed thetoggle from
m_audio->rn2TxEnabled()so a strip popped open after theuser has already enabled RN2 (via preset import or settings restore)
opens showing the correct state:
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 theRNNoiseFilteron toggle-on, holds
m_dspMutexduring allocation, and frees it ontoggle-off. The TX setter should follow the same pattern for
consistency:
The audio-thread read at the call site uses the atomic boolean as the
guard, then reads
m_rn2Txvia raw pointer load — same pattern RX RN2uses today. The mutex protects only the allocation/deallocation; the
audio thread never blocks on it.
Tests
tests/audio_engine_tx_rn2_test.cpp(new):rn2TxEnabled() == false.loads with rn2TxEnabled() == true. Save shape is JSON.
equals input.
from input (denoised). No exact equality check — RNNoise behavior is
model-dependent — but L2 distance > 0.
again, push silent block, output should be silent (no residual tail
from the previous active session).
rn2TxEnabledfrom a worker thread whileanother thread reads it; no torn reads (TSan or ThreadSanitizer-style
sanity).
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_outAccumkeep growing, that's a leak we want to catch here.Verification (manual)
should still be visible, but ~20 px shorter than current, with a small
"RN2" toggle directly beneath it.
confirm WSJT-X / FT8 / SSB receiver hears reduced noise floor.
~/.config/AetherSDR/AetherSDR.settings— confirmAetherialTubePreampTxkey exists, JSON value contains"rn2": true.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_rn2TxEnabledis astd::atomic<bool>— main-thread write, audio-thread read. No mutex in audio callback.m_rn2Txlifetime: allocated lazily underm_dspMutexinsidesetRn2TxEnabled(true), freed under the same mutex insidesetRn2TxEnabled(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
AetherialTubePreampTxis the single key, nested object inside. Mirrors theClientCompTxChain/ClientEqTxChainshape that newer features follow.Out of scope (deferred)
Risks
Implementation order
prepare()allocation +onTxAudioReady()hook — RN2 functional but no toggle yet (defaults off).StripTubePanelmeter shrink + RN2 button + signal — UI lights up.AetherialAudioStripforward signal + MainWindow connect — wire-through.Estimated size
~150-200 LOC of source + ~80 LOC of test. 3-4 hours implementation, 1-2 hours testing.