Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

fix(hl2): Bring up CW, RTTY decoders and the QSO recorder on HL2 - #4537

#4537
Open
jensenpat wants to merge 12 commits into
aethersdr:mainaethersdr/AetherSDR:mainfrom
jensenpat:feat/rx-audio-busjensenpat/AetherSDR:feat/rx-audio-busCopy head branch name to clipboard
Open

fix(hl2): Bring up CW, RTTY decoders and the QSO recorder on HL2#4537
jensenpat wants to merge 12 commits into
aethersdr:mainaethersdr/AetherSDR:mainfrom
jensenpat:feat/rx-audio-busjensenpat/AetherSDR:feat/rx-audio-busCopy head branch name to clipboard

Conversation

@jensenpat

@jensenpat jensenpat commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

The CW decoder, the RTTY decoder and the QSO recorder's RX tap were each connected directly to PanadapterStream::audioDataReady inside wirePanStreamRxAudioSinks(), which early-returns when there is no stream.

On a radio that demodulates in-process (Hermes-Lite 2) all three bound to nothing. No error, no log line — the toggle worked, the panel opened, and nothing ever decoded.

They now ride a single normalized signal, RadioModel::rxDemodAudioReady.

The shape, not the instance

There were three RX audio buses carrying identical payloads — 24 kHz interleaved stereo float32 — on different wires, and every consumer was hard-wired to one at connect() time:

Bus Signal Producer HL2
A — Flex VITA slice audio PanadapterStream::audioDataReady Flex; also the sim's legacy shim ❌ null stream
B — Flex DAX channel audio PanadapterStream::daxAudioReady Flex only ❌ null stream
C — backend seam IRadioBackend::audioFrameReady HL2, sim

Speaker audio and TCI had each been ported to bus C individually, as separate one-off patches. Nothing else was. This PR adds the missing fan-in for the three tap consumers rather than a fourth one-off.

The predicate moved onto the backend

IRadioBackend::ownsRxAudio() replaces a dynamic_cast<SimBackend*> in one place and an m_family != "flex" in another. Both older forms had to be found and updated whenever a backend was added, and #4490's double-feed buzz — the sim's frames arriving over two routes, engine consuming at double rate, measured 48043 Hz against a nominal 24000 — is what happens when one is missed.

It is deliberately not "has no PanadapterStream". The sim has both: a stream carrying the old shim's synthetic scene, and real demodulated audio over the seam. It answers true because the seam is the one that is real.

That silently fixes a live defect. In demo mode the decoders were being fed the synthetic scene while the speaker played the demo's actual demodulated audio — two audio realities in one session, and nothing in the code admitted it.

What was deliberately not touched

AudioEngine::feedAudioData keeps its existing per-family wiring, on every family. Nothing audible changes anywhere.

For a FlexRadio the entire blast radius is: audioDataReady gains one additional subscriber — a signal it already emits, same payload, same thread. The speaker connection, the DAX paths, the TCI paths and the VITA-49 transport are all untouched.

Consumers also bind once in buildUI and are never rebound, because they hang off RadioModel, which outlives the backend swap that destroys and rebuilds a PanadapterStream. That retires the rebind fragility wirePanStreamRxAudioSinks()'s own comment warns about — a sink added at one site and missed at the rebind site is a silently dead feature.

Verification

On the Hermes-Lite 2 at 192.168.1.21, 21.067 MHz:

  • QSO recorder captured 21.6 s of real RX audio — peak 20504, RMS 136.5, 87.5 % non-zero — where the same recording was silence before.
  • CW decode confirmed working on live signals by the operator.
  • RTTY rides the identical signal in the same block; wired, not separately observed on air.

The test asserts the invariant that actually breaks. Not "audio arrives" but exactly one producer — because two producers is not a dead feature, it is a wrong one: every decoder sees each block twice, which a Morse decoder reads as doubled timing, i.e. wrong text rather than no text.

hl2_family_transition_test asserts it across a family round-trip, which is the case Qt cannot clean up for us: the seam relay has this on both ends, so unlike a stream-bound connection there is nothing for Qt to drop when the backend is replaced. The assertion was confirmed to have teeth by removing the disconnect and watching that check — and only that check — fail.

Full suite green: 192/192.

Recorder RX/TX mixing

Worth recording because the reasoning in QsoRecorder's header is Flex-specific:

"While transmitting, the radio mutes the RX stream, so feedRxAudio() would otherwise write full-length silence."

An HL2 demodulates on this host and keeps running through transmit, hearing its own transmitter — so that premise does not hold. The mixing is nonetheless correct, because the gate is a hard mutually-exclusive atomic: feedRxAudio() returns early whenever m_transmitting, and feedTxAudio() whenever not. The radio muting is why the Flex stream happens to be silence; it is not the mechanism. Restoring RX cannot double-write.

Unmeasured and flagged rather than guessed: whether the HL2's in-process RX pipeline flushes a few blocks of transmitter leakage into the start of each RX segment after unkey — the same class as the documented Flex waterfall-freeze window. Worth measuring with a recording running across a real over before deciding it needs a hold-off.

Naming

rxDemodAudioReady is named for the tap it carries: the audio the operator hears, post-AGC and post-passband. A future filter-flat, pre-AGC feed for modems is a separate signal (rxWidebandAudioReady), not a mode flag on this one. The sliceId argument HERMES.md §18.5 proposed is not here either — nothing needs it at one slice, and adding it speculatively would be a parameter with one possible value.

Docs

HERMES.md §18.1 audit table updated, gap 16 closed, Tier 5 item 26 marked partly done, and §18.8 added describing what shipped versus what §18.5 proposed.

Still open in §18 — not in this PR

RADE and the DAX bridge still deref panStream() bare. Now fixed in this PR — see below.

AetherClock (needs slice-identity rather than DAX-channel-identity routing) and the kiwi : "flex" source-tag ternary also remain.

Added after review: the RADE segfault

Correction first. I originally reported this as two crashes, RADE and the DAX bridge. Only RADE crashed. The DAX bridge was already safe — startDax() guards on panStream() before touching anything and its connect sits inside that guarded region, and stopDax() early-returns on !m_daxBridge, which can only be non-null if the guard passed. The error came from grepping for panStream() derefs without checking which ones already sat behind a guard.

RADE was real. activateRADE() ran from its first line to a bare connect(m_radioModel.panStream(), &PanadapterStream::daxAudioReady, …) with no null check in between. Selecting RADE on a Hermes-Lite 2 was a SIGSEGV — same null-deref shape as §6 gap 1, which crashed every HL2 connect three seconds in.

Where the guard goes mattered more than that it exists. Guarding the connect alone would have been wrong: everything between the top of activateRADE() and that line mutates real station state — it moves the TX-slice badge, installs a PTT-off hook on TransmitModel, calls setRadeMode() and opens mic capture. That would leave a radio half in RADE mode with no receive path and an intercepted unkey, which is worse than the crash, because it looks like it worked. The guard goes before the first mutation.

What it resets mattered too. RADE is selected by a toggle (RxApplet/VfoWidget::radeActivated), not by the slice mode — it runs on an ordinary DIGU/DIGL slice. So the decline leaves the operator's mode alone and resets the toggle, using the same three setters deactivateRADE() uses, so a decline and a teardown leave the UI identical. An earlier draft of this fix reverted the slice to USB, which would have fought the operator's own DIGU selection for no reason.

On verifying it compiled: this whole function is inside #ifdef HAVE_RADE, and compile_commands.json listed no entry for the file — so a deliberate syntax error inside the new guard was used to confirm the compiler actually reaches it, rather than trusting a clean build.

HERMES.md §18.3 rewritten with the correction, and the §18.1 rows for RADE and the DAX bridge changed from "dead" to "declines cleanly".

Note

This branch stacks on #4534, and therefore on #4528 / #4503 / #4507.

🤖 Generated with Claude Code

jensenpat and others added 11 commits July 26, 2026 21:57
…o Health

Seven quality-of-life items for the Hermes-Lite 2. Three of them share one
root cause: the GUI drove a Flex command plane this radio does not have, so
the widget moved, the setting persisted, and nothing reached the hardware.

Band switching (Band sidebar)
  The band buttons resolved a Flex band-stack key and sent
  "display pan set <pan> band=". The HL2 has no band stack and no VFO to read
  back, so that command reached nothing. MainWindow now branches on
  usesFlexCommandPlane(): for a non-Flex backend the app is authoritative and
  tunes the slice directly (mode first, then frequency, then pan centre).

  RadioCapabilities gains tuningMinHz/tuningMaxHz so the grid can be honest.
  The HL2 reports 0.1-38.4 MHz (the AD9866's first Nyquist zone) and the 6 m
  button is disabled with a tooltip rather than tuning somewhere it cannot
  hear. Zero/zero means "not reported", so Flex is unaffected.

Companion filter board (J16 open collectors)
  The HL2 has no switchable filters of its own -- it has seven open-collector
  outputs at 0x00[23:17] that the gateware forwards to I2C 0x20. Setting the
  config bits IS the whole mechanism. Selection is derived from the slice
  frequency and auto-applies on connect (from the persisted frequency) and on
  every band crossing.

  The one-hot mapping is Quisk's Hermes_BandDict verbatim -- the grouping
  (60+40 share a filter, 17+15 share one) is a property of the N2ADR board,
  not something inferable from the band plan. Ours is the frequency ranges,
  and the unit test checks them by asserting every amateur band lands on
  Quisk's answer.

  Two departures from "always engage the HPF": nothing below 1.6 MHz (it would
  remove exactly what is being listened to), and no HPF on 160 m (the HL2's
  switching supply couples spurs into those inductors -- wiki Options.md).

  Logged at INFO on a new aether.hl2 category, not debug. There is no readback
  anywhere in the protocol, so that line is the only evidence of what the
  relays were told to do, and a support log has to already contain it.

RF gain -> AD9866 LNA
  lnaGainDb was connect-time only. IRadioBackend::setPanRfGain (default no-op,
  so Flex is untouched) carries the ANT panel's slider to the register at any
  time, and panRfGainInfoChanged reports the real geometry: -12..+48 dB in
  1 dB steps, against the model's Flex-shaped -8..+32 in 8s, which had made
  two thirds of the available gain unreachable.

  Hl2DbReference moves in the same call so the trace and S-meter do not slide
  when gain changes. The persisted key is now family-scoped: a value last set
  on a Flex was being restored onto the HL2 as an LNA gain the operator never
  chose for that radio (observed live -- a Flex-era 16 was pushed at connect).
  And the restore no longer writes a DEFAULT at all: with nothing saved, the
  radio's own gain is the right answer. Seeding from the pan model does not
  fix it -- the restore can run before the backend has published, so the model
  still reads 0 and the same 0 goes to the hardware. Caught by Radio Health
  showing "LNA gain 0 dB" on a fresh profile.

Forward / reverse power (uncalibrated)
  Counts go through Quisk's power_meter_std_calibrations['HL2FilterE3'] curve.
  That is a reference curve for an N2ADR rev E3 board, NOT a calibration of
  this radio -- coupler, toroid and detector diode all vary. The meters are
  labelled "(uncalibrated)" in their own MeterDef descriptions, because the
  number itself looks exactly like a calibrated one. Raw counts still logged;
  a per-unit calibration replaces the table and nothing else.

Radio Health dialog (Help menu, before Slice Troubleshooting)
  New IRadioBackend::healthSnapshot() -- synchronous because every value is
  already cached from unprompted telemetry, so there is no round-trip to
  await. Shows firmware, ADC overload, LNA gain, TX inhibit (decoded from the
  active-low bit), PTT/keyed/tune, TX FIFO depth and under/overflow, PA
  temperature and bias, directional counts and watts, SWR, the J16 filter byte
  and its meaning, tuning/DDC frequency, IQ rate and dropped EP6 packets.
  A register the radio has not reported renders as a dash, never as zero.

Meter pacing
  WDSP handed us ~47 S-meter readings a second and every one crossed to the
  GUI thread. An EMA smooths every sample (so the published value represents
  the whole interval rather than one arbitrary instant) and a 100 ms gate
  decides how often one is published -- dropping without smoothing would
  alias. 100 ms is the cadence MetisClient already paces telemetry at, and the
  attack/decay constants are MeterModel's own Flex forward-power ballistics.

ATU button
  TxApplet greys ATU and MEM when the backend reports hasTuner=false. A live
  ATU button on a radio with no tuner is worse than a missing one: pressing it
  keys the transmitter. updateAtuAvailability() is now the single owner of
  that enabled state -- the radio-has-no-tuner and TGXL-in-Operate reasons
  arrive from different models, and whichever fired last used to win.

Verification
  192/192 tests pass, including 20 new assertions pinning the filter encoding
  and the band map against Quisk's table.

  tests/hl2_live_band_filter_probe.cpp (hardware-only, EXCLUDE_FROM_ALL)
  answers "the unit test shares our reading of the register map, so what would
  catch a convention error?". The filter byte rides the CONFIG register, so a
  misplaced bit lands in the sample rate or receiver count and the failure is
  a radio that still streams and still looks framed. Measured on a real HL2:

    - EP6 packet rate held at 381/s (within 1.002x) across all nine filter
      selections, with non-zero IQ. A bit in DATA[25:24] would have halved or
      doubled it.
    - Engaging the AM-blocking HPF at 0.6 MHz dropped the band 22-24 dB. That
      is not the radio agreeing with our register map -- that is the antenna
      path changing, and it confirms an N2ADR board is fitted. On a bare HL2
      the writes are inert and harmless.
    - A commanded 10 -> 30 dB LNA step moved the raw wire noise floor 19.8 dB,
      measured before any of our DSP or dB referencing runs.

  Driven on hardware through the automation bridge: 40 m button took the slice
  9.98 USB -> 7.200 LSB with the pan following; the filter log went
  0x48 (HPF + 30/20m) -> 0x44 (HPF + 60/40m); Radio Health populated with live
  telemetry; RF Gain read -12..+48 at 20 dB; ATU and MEM disabled with "This
  radio has no antenna tuner"; 6 m disabled as out of range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Hermes-Lite 2 that is never told to stop keeps streaming at a host that is
gone and then stops answering discovery -- alive at the network layer,
invisible to every client, and needing a physical power cycle. Reproduced
three times during aethersdr#4503: a plain `kill` on a connected AetherSDR wedged the
radio every time.

Hl2Backend's destructor already sends metis-stop, which covers a normal quit.
It does not run on a signal -- Qt tears nothing down for SIGTERM, so the
process simply vanishes mid-stream. The gateware watchdog is documented as the
anti-wedge mechanism for exactly this case (MetisProtocol.h,
kRunWatchdogDisable) and did NOT recover it in practice, so this is a belt
alongside that brace rather than a replacement for it.

NOT a self-pipe. The textbook Qt answer is to write to a pipe from the handler
and do the real work back on the event loop. That would be useless here: an
unresponsive event loop is one of the main reasons anyone reaches for kill in
the first place, so the fix must not depend on the thing that may already be
stuck. The handler does the whole job itself, calling nothing but sendto() on
an address resolved in advance -- the descriptor, the sockaddr and the 64-byte
stop datagram are all built by MetisClient::start(), on a normal thread, while
the link is coming up.

Arming happens BEFORE the first start datagram and disarming AFTER the normal
stop has gone out. That asymmetry is deliberate: armed-but-not-streaming costs
a stray 64-byte datagram to a radio that is not listening, while
streaming-but-not-armed is a radio that has to be power-cycled.

Termination signals only -- SIGTERM/SIGINT/SIGHUP/SIGQUIT. Deliberately NOT
SIGSEGV/SIGABRT/SIGBUS: a crash leaves the radio in the same state and it is
tempting to cover it, but those belong to whatever crash reporting the
platform already has (on macOS the reporter uses Mach exception ports, and
MacStartupAbortGuard owns SIGABRT during startup). SIGKILL cannot be caught,
so `kill -9` still wedges the radio; nothing in a process can change that.

An inherited SIG_IGN is left ignored. POSIX says a process should not take
over a signal its parent deliberately neutralised, and this is not academic:
nohup works by ignoring SIGHUP, so installing a handler over it turns a
neutralised signal back into a fatal one. Caught in testing -- a nohup'd run
died the moment its launching shell exited, which is the exact opposite of
what nohup is for.

Verification
  tests/hl2_signal_stop_test.py + hl2_signal_stop_child: a real MetisClient
  runs against a fake radio, the driver kills it with a real signal, and a
  metis-stop datagram must arrive. End-to-end on purpose -- calling
  fireEmergencyStop() directly would prove sendto() works, not that the
  handler is installed, that arming happened, or that the descriptor is still
  usable when the process dies.

  Covers SIGTERM, SIGINT and SIGHUP, checks the datagram is 64 bytes and
  leaves the gateware watchdog ENABLED (disabling it would tell the gateware
  to keep streaming forever at a host that is about to vanish -- the exact
  wedge this exists to prevent), and includes a NEGATIVE CONTROL: the same
  kill with the handlers not installed must send no stop, so the passes mean
  something.

  Confirmed on the real radio by the operator: pkill on a connected session,
  and the HL2 recovered and reconnected with no power cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saved-radio autoconnect was wired to the Flex `RadioDiscovery::radioDiscovered`
signal only. An HL2 arrives on a completely separate sweep — `hl2::Hl2Discovery`
broadcasts HPSDR Protocol 1 discovery on UDP/1024 — and that signal was
connected to the connect-list slots and nothing else. So an HL2 owner with
`AutoConnectToLastRadio` on watched their radio appear in the picker and then
had to click it by hand at every launch, with "Looking for your radio…" on
screen the whole time.

Extract the autoconnect lambda into `MainWindow::maybeAutoConnectToDiscoveredRadio()`
and connect BOTH discovery sources to it. The slot is family-agnostic on
purpose: wiring it to one discovery object is exactly what caused this, and any
future backend that grows its own sweep gets autoconnect for free.

Two behaviours the shared slot adds:

- Fail closed on a busy non-Flex radio, matching the manual-connect gate in
  ConnectionPanel (aethersdr#4448). Protocol 1 is single-client, so connecting to an HL2
  that is already streaming wedges both clients. Says so in the status line
  rather than silently doing nothing — this is the startup path and the
  operator is staring at "Looking for your radio…".
- Also listen to `Hl2Discovery::radioUpdated`, not just `radioDiscovered`. A
  radio first seen as In_Use flips to Available via `radioUpdated` when the
  other client drops; without it the saved radio stays unconnected until it
  disappears from the sweep entirely and comes back.

Flex behaviour is unchanged — same conditions, same order, same messages, now
reached through a named method instead of an inline lambda.

Verified on the HL2 at 00:1C:C0:A2:13:DD (gateware 74) with
`AutoConnectToLastRadio=True` and that MAC saved as `LastConnectedRadioSerial`:
the pre-fix build sat in "Looking for your radio…" for 45s with no autoconnect
while the radio was answering discovery the whole time; the fixed build
connects on its own at startup, confirmed over four consecutive launches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…orks over VPN

The "Connect by IP" page had no way to say what it was dialing. It probed for
a Hermes-Lite 2 first (a ~600 ms HPSDR Protocol 1 discovery datagram to
UDP/1024) and fell through to the FlexRadio TCP/4992 command-plane probe if
nothing answered. That made every Flex connect pay the HL2 timeout, and it
gave an HL2 operator no signal about which protocol had actually been tried
when the connect failed.

Add a **Radio type** dropdown — FlexRadio or Hermes-Lite 2 — and probe exactly
the family the operator picked. The two wire protocols are disjoint: a
Hermes-Lite 2 never answers the Flex probe, and a FlexRadio never answers a
Metis discovery datagram, so there is nothing to gain from trying both.

A directed (unicast) Metis discovery is the whole reason connect-by-IP matters
for an HL2 — Hl2Discovery's broadcast sweep never leaves the local subnet, so
a radio across a VPN or a routed subnet was unreachable from the UI.

Also:

- Extract the probe into ConnectionPanel::probeHermesLite2() and reuse
  MetisProtocol's discoveryRequest()/parseDiscoveryReply()/isHermesLite2()
  instead of the hand-rolled byte poking the inline version used, and promote
  Hl2Discovery::macToSerial() so a directed probe produces byte-identical
  identity to a broadcast sweep. That serial IS the auto-reconnect key and the
  client-side nickname key, so the two paths must agree.
- Honour the Advanced VPN source-path selection in the HL2 probe. It bound to
  AnyIPv4 unconditionally, which on a VPN exposing several adapters can send
  the request out the wrong interface.
- Remember a successful HL2 address: it now calls rememberManualIp() and
  saveManualProfile() like the Flex path already did, so an HL2 lands in the
  recent-addresses dropdown, and the chosen family is persisted both globally
  (ConnectByIpRadioFamily) and per-address (routed profile identity.family) so
  picking a recent address restores its type. Profiles written before this
  carry no family and are read as flex, which is what they were.
- Mark the HL2 result isRouted, matching what the Flex manual probe records.

Bridge: `connect ip <host-or-ip> [flex|hl2]`. Omitting the type keeps the
dialog's current selection, so existing scripts are unaffected; the response
echoes "family". Rejects anything that is not flex or hl2 rather than silently
probing the wrong protocol.

Layout: adding a second row overflowed the dialog, and Qt resolved the
shortfall by overlapping the labels with their own entry fields. The nested
QFormLayout is replaced with explicit label+field rows, the wrapped result
label now reserves its height instead of reflowing the page when a message
appears, and the height floor comes from the panel's own content rather than
the hardcoded 580/660 in MainWindow.

Verified against the Hermes-Lite 2 at 192.168.1.21 over a VPN (broadcast
discovery cannot see it): connected by IP from both the bridge verb and the
GUI button, serial 00:1C:C0:A2:13:DD and gateware 74 matching a raw discovery
probe exactly, live off-air RF on the panadapter (WWV at 10.000 MHz). Selecting
FlexRadio for that same address correctly does not connect, and the radio was
left answering discovery 0x02 idle. Full suite green, 192/192.

Includes aethersdr#4503 and aethersdr#4507.
…-bus audit

The WSPR beacon was refused outright on any non-Flex family. The reasoning was
sound for a family that needs a `dax_tx` stream: ensureDaxTxStream() returns
true optimistically on a pending `stream create`, so a backend whose command
sink drops that text would "succeed" with a stream that never arrives and leave
`transmit dax` latched for minutes.

A host-modulating backend needs no stream. The transmit route already existed,
built for TCI in aethersdr#4471 — pumpWsprBeacon() → feedDaxTxAudioInternal()'s
m_hostModulation branch → txFinalMonitorPcmReady → submitTxAudio. So this adds
an early arm in prepareWsprTransmit() that latches and returns true, the
matching release, and teaches hasWsprTxStream() that "the route is ready" and
"a dax_tx stream exists" are different claims.

Three things that looked like blockers and were not:

- Mic collision. Two producers into one modulator would put shack ambience on
  the frame, and the HL2 has no radio-side mic mute to hide behind. But
  startWsprPump() already calls setDaxTxMode(true), and onTxAudioReady()
  early-returns on m_daxTxMode before emitting txFinalMonitorPcmReady. The
  suppression is local and family-independent — written for Windows and for
  Linux-without-PipeWire, and exactly what a host-modulating backend needs.
- Interlock timeout. m_interlockTimeout defaults to 0, HL2 never sets it, and
  isInterlockTimeoutSufficient(0) is true.
- Keying. requestPttOn(PttSource::Wspr) already reaches setKeying() via
  moxCommandIssued on every non-Flex family.

`transmit filter_low/high` stays Flex-only and is now documented as advisory
rather than dropped: the HL2 derives its TX passband from the mode (DIGU →
150…3000 Hz), which contains the 1400–1600 Hz WSPR offset, and the tone is a
single ~6 Hz-wide 4-FSK carrier.

HERMES.md gains §18: the audit of which voice/data features actually work on
this radio. Three RX audio buses carry identical payloads on different wires;
every consumer is hard-wired to one at connect() time. CW, RTTY, the QSO
recorder's RX tap, AetherClock, RADE, the DAX bridge and the DAX-IQ applet are
all dead on HL2 for that reason alone. ASR, the AX.25/APRS/PMS stack and TCI
work because they tap AudioEngine rather than a transport. Two of the dead
ones (RADE, DAX bridge) deref panStream() bare and are a SIGSEGV on mode
change, not a decline — flagged as the thing to fix ahead of this.

§13 gains Tier 5 for the bus work; §6 cross-references gaps 16–19.

Tests: the hl2_family_transition_test block that asserted the refusal now
asserts the host-modulated arm, that it does NOT latch `transmit dax`, that
release clears it, that it is re-armable, and that no claim survives a
round-trip back to Flex. 192/192 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the air

Selecting a WSPR band updated the status label and touched nothing else, on the
reasoning that selecting is a choice rather than a command. From the operator's
seat that is a broken control: the label reads "40m · 7.038600 MHz USB on
transmit" while the dial sits on 10 MHz, there is no way to look at the
sub-band (or its SWR) before committing, and the entire band change — NCO,
band-filter relays, TX oscillator — then happens inside the last seconds before
a 111.6-second transmission.

Selecting a band now tunes the receiver. Only the dial moves; mode, slice
passband and the station TX filter stay deferred to applyBeaconBand() at arm
time, because those are the parts that disturb a listening operator's setup and
they get restored afterwards. Guarded on operator intent — updateBeaconDefaults()
drives this combo from the slice behind a QSignalBlocker, so a programmatic sync
cannot tune the radio back at itself — and on armed / transmitting / locked.

On-air verification, KI6BCJ/DM06, 40 m, 2026-07-28 00:42:00 UTC, ~3.5 W into a
real antenna:

- Status went "Armed · starts in 81.6 s" where the old build said "WSPR TX audio
  route is unavailable" instantly. Keyed at 00:42:00.020 on tx_frequency
  7038600, ran all 162 symbols, reported "Complete".
- 21 stations decoded it, 284 km to 2080 km, best SNR +1 dB.
- Sideband confirmed FROM OUTSIDE: dial + 1500 Hz offset lands at 7.040100 on
  USB, 7.037100 if inverted. Reported 7040098-7040113, centred 7040100, drift 0
  on every spot. This is the check HERMES.md §14.6 could not construct
  internally, and it is why this feature is worth more than its diff.
- MICPEAK -20 dBFS during the frame — exactly beacon->start()'s level, proving
  the generator and not the mic fed the modulator.

SWR measured 3.3 at 7.0386; it completed safely (PA 43.6 -> 47.4 °C, still
decelerating at unkey) but that is not a duty cycle to make routine into that
load. Recorded in HERMES.md §18.4 along with the `transmit dax` reading, which
looks like a latch and is actually the aethersdr#2273 digital-mode rule agreeing with the
slice being deliberately left in DIGU.

192/192 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s none

Three facts about the OPERATOR were read from the RADIO, so on a radio that
stores none of them the app behaved as though the station had no identity.

- Callsign came from RadioInfo, the Flex `info` reply, and `radio callsign <x>`.
  On an HL2 Radio Setup accepted an edit, sent Flex text nobody was listening
  for, and read back blank. PSK Reporter then had no callsign to query, so the
  map stayed empty and the status bar read "No callsign — connect to a radio
  first" against a perfectly connected radio.
- The WSPR grid was prefilled from RadioModel::gpsGrid(), a 6000-series GPSDO
  reading, and had no persistence of its own — retyped every session, and a
  beacon armed with a blank grid is rejected by the encoder.
- Map home position came from GPSDO lat/lon and then the GPSDO grid.
  updateHomeFromRadio() returned having set nothing, so MapView had no origin.

That last one fails the most quietly: every received spot still drew correctly
(each carries its own coordinates) and NO PATHS drew, because there was no home
to draw them from. Nothing errored — it just looked like a map missing its lines.

Each now falls back to a client-side value:

- RadioModel::callsign() falls back to a station-wide StationCallsign setting.
  Station-wide, not per-serial: unlike the nickname, which really is a property
  of one radio, a callsign belongs to the operator and is the same on every
  radio they own. The radio's value still wins when present, so a Flex is
  unchanged. Radio Setup persists on every family and additionally sends
  `radio callsign` where there is an on-radio store — send first, persist
  second, so the callsignChanged emit cannot publish the stale radio value.
- The WSPR grid persists as `beaconGrid`, next to the power and tone settings
  that already did, and a GPSDO-derived value is written through so it survives
  onto a radio without one.
- updateHomeFromRadio() takes the operator's grid as a third source after the
  two GPSDO ones, and no longer bails on a null model — the map is a network
  view and is usable before a radio is up. A 4-character square is ~70 x 100 km:
  coarse for a fix, fine for a path across a continent. Editing the grid now
  moves the marker and redraws the paths instead of waiting for a reopen.

The beacon callsign field writes through to the same station callsign rather
than a beacon-private copy, so the map query and the transmitted identity cannot
disagree — which was the half-state this dialog was in.

Also: the Radio Setup callsign and nickname edits were anonymous QLineEdits
among many, reachable by neither a screen reader nor the automation bridge
(which resolves by objectName / class / accessibleName). Both are now named.

HERMES.md §18.6.1 records the gap class: anything the app knows about the
OPERATOR needs a client-side home, because its absence looks like "no results"
rather than an error.

192/192 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CW decoder, the RTTY decoder and the QSO recorder's RX tap were each
connected directly to PanadapterStream::audioDataReady inside
wirePanStreamRxAudioSinks(), which early-returns when there is no stream. On a
radio that demodulates in-process (HL2) all three bound to nothing: no error, no
log line, the toggle worked and nothing ever decoded.

They now ride RadioModel::rxDemodAudioReady — one signal, one producer chosen in
one place, carrying the 24 kHz stereo float32 both existing producers already
emit byte-for-byte.

The producer choice moves onto the backend as IRadioBackend::ownsRxAudio(),
replacing a dynamic_cast<SimBackend*> and an m_family != "flex". Both earlier
forms had to be found and updated whenever a backend was added, and the
double-feed buzz of aethersdr#4490 is what happens when one is missed. Note it is NOT
"has no PanadapterStream": the sim has both a stream carrying the old shim's
synthetic scene AND real demodulated audio over the seam, and answers true
because the seam is the real one. That also fixes a live inconsistency — in demo
mode the decoders were being fed the synthetic scene while the speaker played
the demo's actual audio, two audio realities in one session.

Consumers bind once in buildUI and are never rebound. RadioModel outlives the
backend swap that destroys and rebuilds a PanadapterStream, which is the
fragility wirePanStreamRxAudioSinks()'s own comment warns about — a sink added
at one site and missed at the rebind site is a silently dead feature.

Deliberately NOT the speaker path. AudioEngine::feedAudioData keeps its existing
per-family wiring exactly as it was, on every family, so nothing audible changes
and the Flex pipeline is untouched apart from gaining one additional subscriber
to a signal it already emits.

Named rxDemodAudioReady for the tap it carries: a future filter-flat, pre-AGC
feed for modems is a separate signal, not a mode flag on this one (HERMES.md
§18.5).

Verified on the Hermes-Lite 2 at 192.168.1.21: the QSO recorder captured 21.6 s
of real RX audio at 21.067 MHz — peak 20504, RMS 136.5, 87.5% non-zero — where
the same recording was silence before. CW decode confirmed working on live
signals by the operator.

The new test asserts the invariant that actually breaks: exactly one producer,
including across a family round-trip, where the seam relay has `this` on both
ends so Qt has nothing to drop for us. Confirmed the assertion has teeth by
removing the disconnect and watching that check — and only that check — fail.
192/192 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updates the §18.1 audit table (CW, RTTY and the QSO recorder's RX half move from
dead to working), closes gap 16, marks Tier 5 item 26 partly done, and adds
§18.8 describing what actually shipped versus what §18.5 proposed.

The three things worth carrying forward: the predicate now lives on the backend
(ownsRxAudio) rather than in a cast or a family-name compare; the speaker path
was deliberately left alone on every family, which is the blast radius to copy
for the remaining items; and the invariant worth testing is "exactly one
producer", because two is a WRONG feature rather than a dead one — a decoder
seeing each block twice reads doubled timing.

Also records that the recorder's RX/TX mixing is safe for a different reason
than its own header claims, and flags the unkey-edge flush as unmeasured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jensenpat jensenpat changed the title feat(rx): one normalized RX-audio bus, so CW, RTTY and the QSO recorder work on any radio fix(hl2): bring up CW, RTTY decoders and the QSO recorder Jul 28, 2026
@jensenpat
jensenpat marked this pull request as ready for review July 28, 2026 02:43
@jensenpat
jensenpat requested review from a team as code owners July 28, 2026 02:43
@jensenpat jensenpat changed the title fix(hl2): bring up CW, RTTY decoders and the QSO recorder fix(hl2): Bring up CW, RTTY decoders and the QSO recorder on HL2 Jul 28, 2026
activateRADE() ran from its first line to a bare
connect(m_radioModel.panStream(), &PanadapterStream::daxAudioReady, ...) with no
null check in between. Only a Flex backend owns a PanadapterStream, so selecting
RADE on a Hermes-Lite 2 was a SIGSEGV — the same null-deref shape as the one
that crashed every HL2 connect three seconds in (HERMES.md §6 gap 1).

Where the guard goes matters more than that it exists. Guarding the connect
alone would be wrong: everything between the top of activateRADE() and that line
mutates real station state — it moves the TX-slice badge, installs a PTT-off
hook on TransmitModel, calls setRadeMode() and opens mic capture. That would
leave a radio half in RADE mode with no receive path and an intercepted unkey,
which is worse than the crash because it looks like it worked. The guard goes
before the first mutation.

What it resets matters too. RADE is selected by a toggle (RxApplet /
VfoWidget::radeActivated), not by the slice mode — it runs on an ordinary
DIGU/DIGL slice. So the decline leaves the operator's mode alone and resets the
toggle, using the same three setters deactivateRADE() uses, so a decline and a
teardown leave the UI in the identical state. An earlier draft reverted the
slice to USB, which would have fought the operator's own DIGU selection.

Also corrects HERMES.md §18.3, which claimed RADE AND the DAX bridge both
crashed. Only RADE did. startDax() already guards on panStream() before touching
anything and its connect is inside that guarded region, and stopDax()
early-returns on !m_daxBridge. The error came from grepping for panStream()
derefs without checking which already sat behind a guard — worth recording,
since the same grep would make the same mistake again.

Verified the guard is actually compiled: HAVE_RADE wraps this whole function and
compile_commands.json listed no entry for the file, so a deliberate syntax error
inside the guard was used to confirm the compiler reaches it.

192/192 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice piece of work — the diagnosis is right and the shape of the fix is the right one. Binding the taps to RadioModel rather than to whichever transport happens to exist is exactly the correction, the disconnect in wireRxDemodAudioBus() is the load-bearing line and the round-trip test earns its place, and the RADE guard is placed correctly (before the first mutation, resetting the toggle rather than the mode) with reasoning I'd want in the tree. Verification is real: hardware measurements, an operator-confirmed CW decode, and a self-correction in the PR body about the DAX bridge that I checked and agree with — startDax() does guard first.

One thing I'd like tightened before merge, and it's the PR's own central claim rather than a new bug: the predicate did not actually move. MainWindow::backendOwnsRxAudio() is still a dynamic_cast<SimBackend*>, and it now shares a name with IRadioBackend::ownsRxAudio() while meaning something different — details inline.

Would like fixed before merge

  • MainWindow::backendOwnsRxAudio() still dynamic_casts, and the name now collides with IRadioBackend::ownsRxAudio() with a different truth set for HL2 — "finishing the unification" would silence HL2 speaker audio (src/gui/MainWindow_Session.cpp:1758)

Polish

  • HERMES.md §18.8's premise for the recorder RX/TX reasoning is contradicted by Hl2Backend.cpp:321 (HERMES.md:1943)
  • The round-trip test never asserts the zero case on the Flex leg (tests/hl2_family_transition_test.cpp:105)

Non-blocking notes

  • .claude/launch.json carries a machine-local build path and a stale branch name (feat-hl2-connect-by-ip). It arrives via d114a4f, so it belongs to #4534 upstream of here — worth dropping there rather than in this branch.
  • Scope reads clean for the three commits that are actually this PR (5557cd8, e7f66d8, d7e70fb); everything else is the #4534 / #4528 / #4503 / #4507 stack, so this wants merging in order.
  • CodeGuard's six CG-PATH-001 findings are all false positives and I've dropped them: the five in MainWindow.cpp are the FFTW-wisdom dialog and the UI-scale self-relaunch (paths derived from QCoreApplication::applicationFilePath(), untouched by this PR), and hl2_live_band_filter_probe.cpp:98 is argv[1] into QHostAddress in a hardware-only EXCLUDE_FROM_ALL probe.
  • CI is green across all eight checks on d7e70fb.

🤖 aethersdr-agent · cost: $8.6893 · model: claude-opus-5

Comment on lines +1758 to +1759
// The primary RX audio → QAudioSink connection above stays exactly where it
// was, deliberately: nothing audible changes on any family.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR body and HERMES.md §18.8 both say IRadioBackend::ownsRxAudio() "replaces a dynamic_cast<SimBackend*> in one place and an m_family != \"flex\" in another." It doesn't — it's a new third call site. MainWindow::backendOwnsRxAudio() (line 1725) is still dynamic_cast<SimBackend*>(m_radioModel.backend()) != nullptr, and it gates both speaker-path sites: the feedAudioData relay at line 200 and the QAudioSink connect just above this comment. Those are the two that actually produced #4490's double-feed, so the "a backend added later can't miss it" property the new virtual buys applies to the tap bus only.

The part that worries me more than the stale claim is the name. The two predicates now read identically and mean different things:

  • IRadioBackend::ownsRxAudio() — "audio arrives over the seam" → HL2 true, sim true, Flex false.
  • MainWindow::backendOwnsRxAudio() — "the backend already feeds the engine directly, so don't relay" → HL2 false, sim true, Flex false.

So the obvious follow-up cleanup — delegating MainWindow::backendOwnsRxAudio() to backend()->ownsRxAudio() — makes line 200's if (backendOwnsRxAudio()) return; swallow every HL2 frame and silences the speaker on the radio this PR exists to support. That's a trap someone will walk into precisely because the docs say the unification already happened.

Two small things would close it: rename the MainWindow helper to what it actually asks (backendFeedsEngineDirectly(), say) with a line saying it is deliberately not ownsRxAudio() and why, and soften the §18.8 / PR-body sentence to "adds the predicate the tap bus keys off" rather than "replaces."

Comment thread HERMES.md
Comment on lines +1943 to +1945
`QsoRecorder`'s header states *"while transmitting, the radio mutes the RX
stream"* — a Flex fact. An HL2 demodulates on this host and keeps running
through transmit, hearing its own transmitter. The mixing is nonetheless correct

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This premise doesn't hold. Hl2Backend.cpp:317-323 drops the frame outright while keyed:

connect(m_dsp, &Hl2RxDsp::audioReady, this, [this](const std::vector<float>& pcm) {
    if (m_keyed)
        return;
    emit audioFrameReady(floatBytes(pcm));
});

plus Hl2RxDsp::setAudioMuted stopping the pipeline from filling. So the HL2 does not keep feeding RX audio through transmit — rxDemodAudioReady goes silent for the duration, same observable behaviour as the Flex stream mute the QsoRecorder header describes, just achieved on this side of the wire.

The conclusion is unaffected (the m_transmitting gate is still the mechanism, and it's still a hard mutual exclusion), but since §18.8 is the record the next reader will trust, it'd be better as "the HL2 mutes at the backend rather than at the radio, so the Flex-shaped premise is coincidentally satisfied by a different mechanism."

That also mostly answers the Still open item three lines down: that same m_keyed gate plus setAudioMuted is a hold-off already. What's genuinely unmeasured is only the unkey edge — whether m_keyed clears before the last leakage-contaminated block drains — which is a narrower and more testable question than the one written here.

Comment on lines +104 to +105
model.connectToRadio(flexInfo());
model.connectToRadio(hl2Info());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small gap: the round-trip asserts one producer on the HL2 leg at both ends, but never checks the zero case in the middle. On the Flex leg the seam relay must be gone, and that's the state the disconnect in wireRxDemodAudioBus() exists to produce — asserting it directly is one line and names the failure more precisely than a busBlocks == 2 at the end would.

Suggested change
model.connectToRadio(flexInfo());
model.connectToRadio(hl2Info());
// Flex -> HL2 again: re-enters wireRxDemodAudioBus() with the same
// endpoints on the seam relay.
model.connectToRadio(flexInfo());
busBlocks = 0;
emit model.backendAudioFrameReady(frame);
check(busBlocks == 0,
"RX bus: the seam relay is dropped when a Flex takes over");
model.connectToRadio(hl2Info());

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (6 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4537/src/gui/MainWindow.cpp:7160
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4537/src/gui/MainWindow.cpp:7194
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4537/src/gui/MainWindow.cpp:7299
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4537/src/gui/MainWindow.cpp:7300
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4537/src/gui/MainWindow.cpp:7301
  • [MEDIUM] CG-PATH-001 — Potential path traversal in tests/hl2_live_band_filter_probe.cpp /tmp/aetherclaude/pr-4537/tests/hl2_live_band_filter_probe.cpp:98

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $8.8154 · model: claude-opus-5

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

Morty Proxy This is a proxified and sanitized view of the page, visit original site.