fix(hl2): Bring up CW, RTTY decoders and the QSO recorder on HL2 - #4537
#4537fix(hl2): Bring up CW, RTTY decoders and the QSO recorder on HL2#4537jensenpat wants to merge 12 commits into
Conversation
…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>
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>
There was a problem hiding this comment.
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()stilldynamic_casts, and the name now collides withIRadioBackend::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 byHl2Backend.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.jsoncarries a machine-local build path and a stale branch name (feat-hl2-connect-by-ip). It arrives viad114a4f, 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.cppare the FFTW-wisdom dialog and the UI-scale self-relaunch (paths derived fromQCoreApplication::applicationFilePath(), untouched by this PR), andhl2_live_band_filter_probe.cpp:98isargv[1]intoQHostAddressin a hardware-onlyEXCLUDE_FROM_ALLprobe. - CI is green across all eight checks on
d7e70fb.
🤖 aethersdr-agent · cost: $8.6893 · model: claude-opus-5
| // The primary RX audio → QAudioSink connection above stays exactly where it | ||
| // was, deliberately: nothing audible changes on any family. |
There was a problem hiding this comment.
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."
| `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 |
There was a problem hiding this comment.
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.
| model.connectToRadio(flexInfo()); | ||
| model.connectToRadio(hl2Info()); |
There was a problem hiding this comment.
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.
| 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()); |
|
Cisco CodeGuard — static analysis of this PR (6 finding(s))
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 |
What
The CW decoder, the RTTY decoder and the QSO recorder's RX tap were each connected directly to
PanadapterStream::audioDataReadyinsidewirePanStreamRxAudioSinks(), 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:PanadapterStream::audioDataReadyPanadapterStream::daxAudioReadyIRadioBackend::audioFrameReadySpeaker 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 adynamic_cast<SimBackend*>in one place and anm_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
truebecause 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::feedAudioDatakeeps its existing per-family wiring, on every family. Nothing audible changes anywhere.For a FlexRadio the entire blast radius is:
audioDataReadygains 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
buildUIand are never rebound, because they hang offRadioModel, which outlives the backend swap that destroys and rebuilds aPanadapterStream. That retires the rebind fragilitywirePanStreamRxAudioSinks()'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:
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_testasserts it across a family round-trip, which is the case Qt cannot clean up for us: the seam relay hasthison 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 thedisconnectand 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: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 wheneverm_transmitting, andfeedTxAudio()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
rxDemodAudioReadyis 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. ThesliceIdargument 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 derefNow fixed in this PR — see below.panStream()bare.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 onpanStream()before touching anything and itsconnectsits inside that guarded region, andstopDax()early-returns on!m_daxBridge, which can only be non-null if the guard passed. The error came from grepping forpanStream()derefs without checking which ones already sat behind a guard.RADE was real.
activateRADE()ran from its first line to a bareconnect(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
connectalone would have been wrong: everything between the top ofactivateRADE()and that line mutates real station state — it moves the TX-slice badge, installs a PTT-off hook onTransmitModel, callssetRadeMode()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 settersdeactivateRADE()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, andcompile_commands.jsonlisted 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