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

feat(hl2): basic TCI signaling — WSJT-X over a Hermes-Lite 2 - #4471

#4471
Merged
ten9876 merged 8 commits into
aethersdr:mainaethersdr/AetherSDR:mainfrom
jensenpat:feat/hl2-tci-signalingjensenpat/AetherSDR:feat/hl2-tci-signalingCopy head branch name to clipboard
Jul 26, 2026
Merged

feat(hl2): basic TCI signaling — WSJT-X over a Hermes-Lite 2#4471
ten9876 merged 8 commits into
aethersdr:mainaethersdr/AetherSDR:mainfrom
jensenpat:feat/hl2-tci-signalingjensenpat/AetherSDR:feat/hl2-tci-signalingCopy head branch name to clipboard

Conversation

@jensenpat

@jensenpat jensenpat commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator
image

Initial bring up of TCI data stack on Hermes Lite 2.

What was broken

Why it was silent
RX audio wirePanStreamTciSinks() binds TCI's audio feed to PanadapterStream, which an in-process backend doesn't have — so nothing was bound and TCI clients heard silence.
TX confirmation TciServer waits on RadioModel::radioTransmittingChanged to confirm a TCI key, and gives up 1250 ms later. That edge is decoded from Flex interlock status and never arrived, so every WSJT-X transmission was unkeyed a second and a quarter in, mid-tone.
TX audio AudioEngine::feedDaxTxAudio returned immediately unless a Flex TX stream id was set. A host-modulating backend has none and never will — its modulator is local. Every TCI audio frame was dropped and the radio transmitted silence while reporting a normal transmit.
Tuning The VFO set went through sendCmdPublic(), which is swallowed on a seam backend and never runs its completion callback — so the band hop was dropped and WSJT-X's 2 s vfo-echo timeout expired on top of it.

What changed

  • RX audio — route RadioModel::backendAudioFrameReady to onDaxAudioReady() on channel 1. The payload is already float32 stereo at 24 kHz, and the channel→TRX fallback maps 1 → trx 0, which is the whole mapping on a single-slice radio.
  • TX confirmation — publish the raw-TX edge from our own keying command on a backend that has no status plane to be authoritative instead. Applied on both setTransmit() and the moxCommandIssued path, since the MOX button and PTT coordinator use only the latter.
  • TX audio — hand it to the same final-monitor tap the microphone uses, which already routes to submitTxAudio(). Still a DSP bypass; these are pre-shaped digital tones.
  • Tuning — drive the model instead of the wire; it routes through IRadioBackend and is authoritative on return.
  • Skip the Flex DAX arrangements (ensureDaxForTci, the dax=1 TX route) on a backend with no DAX plane, rather than emitting Flex text at a radio speaking HPSDR.

Two mode-table bugs found while verifying

Both broke WSJT-X directly:

  • The HL2 passband was sticky across mode changes. We own the DSP, so nothing echoes a mode-appropriate filter back the way a Flex does — arriving at DIGU from CW handed the decoder a ~500 Hz window. Added a per-mode default passband, applied on change only so an operator's own filter edit survives (oracle addendum 2 §B3: "All clients tie default filter width to mode, with user overrides").
  • modeFromString knew "CWU" but not "CW" — the spelling TciProtocol produces for TCI's cw, and the one a Flex reports. Plain CW fell through to the USB fallback and was demodulated as SSB, with the mode indicator reading CW. NFM was missing for the same reason.

Verification — live Hermes-Lite 2 (00:1C:C0:A2:13:DD)

Driven by a stdlib-only TCI client that shares no code with AetherSDR, so a convention error on both sides can't hide.

  • Handshaketrx_count:1, one slice, one pan, every field WSJT-X reads present
  • RX audio — 187 frames / 382,976 floats in 4 s (previously zero)
  • VFO set — echoed in 15–39 ms, against a 2 s client timeout
  • Keying — held the full 4 s with no spurious unkey (previously died at 1.25 s)
  • TX audio reaches the modulator — forward-power counts peak at 1533 with audio vs 0–1 keyed silent; TX FIFO steady at 26–30, no under/overflow. Confirmed in DIGU, the mode WSJT-X actually uses.
  • Per-mode passbands — correct for cw / digu / digl / usb / lsb / am / cwr

TX testing was into a dummy load on 7.200, then DIGU, at 1% and 30% drive.

New hl2_tci_signaling_test covers the seam (edge publication on both keying paths, Flex staying interlock-owned, the command-plane predicate, host-modulated TX audio conversion, and the per-mode passband table). Full suite: 178/178.

Known gaps

  • vfo_limits still advertises the Flex 1000,75000000 rather than the HL2's real range — harmless for WSJT-X, worth making honest later.
  • tx_sensors mic_dbm is inert on HL2: micMetersChanged carries the MIC meter and HL2 defines MICPEAK. Meter plumbing, not signaling.
  • RTTY has no HL2 mode mapping and still falls back to USB; left alone rather than guessed at.

🤖 Generated with Claude Code

@jensenpat

Copy link
Copy Markdown
Collaborator Author

RX handedness fix — the demodulator and the spectrum were wired to each other's convention

Operator reported: DIGU at 7.074 put FT8 at 7.071 on the panadapter and decoded nothing, while LSB/DIGL at the same dial lined up and decoded perfectly — and LSB transmit produced real spotted QSOs. A radio that is completely correct in the wrong mode isn't a mode bug; the whole chain was coherently inverted.

Hl2RxDsp handed the demodulator the conjugate of the wire IQ and the spectrum the raw wire. Both backwards. Two facts, measured against WWV on live hardware rather than reasoned from convention:

  1. The HPSDR wire is the conjugate of the analytic convention — a signal above the NCO arrives at a negative frequency.
  2. WDSP's RXA, as configured here, selects the opposite sign to its passband bounds — USB with [+150,+3000] passes negative frequencies. Confirmed independently by hl2_rxdsp_test and hl2_shift_test.

So the demodulator wants the raw wire (1 and 2 cancel) and the spectrum wants the conjugate.

The measurement that settled it

Forcing the WDSP slice shift to zero — the one geometry where no second error can compensate. Old chain, all four modes inverted by 100–300× in magnitude:

mode heard wanted
usb below dial above
digu below dial above
lsb above dial below
digl above dial below

The shift sign is unchanged

It looked like a co-conspirator and isn't. Once handedness is settled it's derivable: the wire puts F at -(F - NCO), so shift = slice - NCO maps the slice to baseband. It only looked wrong because hl2_shift_test validated it in LSB — the one mode the conjugation bug made correct. I tried flipping it; that measurably broke off-centre tuning, and the test caught it.

Both DSP tests were feeding IQ no HL2 ever sends

That's why a mirrored panadapter and an inverted demodulator both passed:

  • hl2_rxdsp_test fed exp(+jwt) for a signal above centre; the wire sends exp(-jwt).
  • hl2_shift_test same — its "tone 800 Hz below the NCO" was really 800 Hz above.

Both now generate wire order. Assertions unchanged, which is the point: the expectations were always right, the stimulus wasn't.

Verification — live HL2, receive only

  • Zero shift: usb/digu hear above the dial, lsb/digl below, 100–150× margin
  • Normal off-centre tuning: all four correct, recovered tone exactly 1500 Hz on a 1500 Hz offset — confirms the shift sign
  • 20 m panadapter: FT8 renders inside the DIGU passband at 14.074–14.077, right of the cursor — confirmed by the operator ("that lines up perfect")

20m DIGU after the fix

Full suite 178/178. No transmissions during this work — the radio was on a live antenna throughout.

One thing noticed, not fixed

At zero shift the recovered tone read 1300/1700 Hz against a 1500 Hz offset — a consistent ~200 Hz high receive offset (≈20 ppm at 10 MHz, in line with an uncalibrated HL2 reference). Harmless for FT8 and unrelated to handedness, but worth a calibration knob later.

@jensenpat

Copy link
Copy Markdown
Collaborator Author

On-air confirmation: DIGU transmit — 63 PSK Reporter spots on 14.074

The last item I had flagged as unproven. The TX chain was unit-proven at 77–85 dB opposite-sideband suppression off the modulator's own IQ, but it had never been on the air in DIGU — every real QSO before this was LSB through the inverted chain.

63 spots on PSK Reporter, 20 m FT8, DIGU.

This is third-party evidence from independent receivers worldwide, so it validates the whole transmit path end to end in a way nothing in this repo can:

  • Sideband — DIGU lands where the FT8 network expects it. A wrong-sideband signal is un-decodable, so the spots cannot happen by accident.
  • Frequency accuracy — including the ~200 Hz uncalibrated-reference offset noted earlier, which is confirmed to be well inside what FT8 tolerates.
  • Modulator quality — the audio survives the local WDSP TX chain, the 24→48 kHz rate conversion, and the DUC intact enough to decode at distance.

Combined with the receive-side proof above, both directions are now confirmed correct in the modes WSJT-X actually uses, on real hardware and on the air.

jensenpat and others added 4 commits July 26, 2026 08:27
TCI on an HL2 completed its handshake and then did nothing useful: WSJT-X
showed a connected rig, tracked frequency and mode, and never decoded or
transmitted anything. Four separate failures, each silent in its own way,
because the TCI server was written against a Flex and an HL2 has neither of
the two planes it assumes — no DAX/VITA-49 data plane, and no SmartSDR text
command plane.

RX audio  wirePanStreamTciSinks() binds TCI's audio feed to PanadapterStream,
          which an in-process backend does not have, so nothing was bound and
          TCI clients heard silence. Route RadioModel::backendAudioFrameReady
          to onDaxAudioReady() on channel 1 — the payload is already float32
          stereo at 24 kHz, and the channel→TRX fallback maps 1 → trx 0, which
          is the whole mapping on a single-slice radio.

TX confirm TciServer waits on RadioModel::radioTransmittingChanged to confirm
          a TCI key and gives up 1250 ms later. That edge is decoded from Flex
          `interlock` status and never arrived, so every WSJT-X transmission
          was unkeyed a second and a quarter in, mid-tone. Publish the edge
          from our own keying command on a backend that has no status plane to
          be authoritative instead — via both setTransmit() and the
          moxCommandIssued path, since the MOX button uses only the latter.

TX audio  AudioEngine::feedDaxTxAudio returned immediately unless a Flex TX
          stream id was set. A host-modulating backend has none and never will:
          its modulator is local. Hand the audio to the same final-monitor tap
          the microphone uses, which already routes to submitTxAudio(). Still a
          DSP bypass — these are pre-shaped digital tones.

Tuning    The VFO set went through sendCmdPublic(), which is swallowed on a
          seam backend AND never runs its completion callback, so the band hop
          was dropped and WSJT-X's 2 s vfo-echo timeout expired on top of it.
          Drive the model instead; it routes through IRadioBackend and is
          authoritative on return.

Also skips the Flex DAX arrangements (ensureDaxForTci, the dax=1 TX route) on a
backend with no DAX plane, rather than emitting Flex text at a radio speaking
HPSDR.

Two mode-table bugs surfaced while verifying the above, both of which broke
WSJT-X directly:

  - The HL2 passband was sticky across mode changes. We own the DSP, so
    nothing echoes a mode-appropriate filter back the way a Flex does, and
    arriving at DIGU from CW handed the decoder a ~500 Hz window. Add a
    per-mode default passband, applied on change only so an operator's own
    filter edit survives (oracle addendum 2 §B3).
  - modeFromString knew "CWU" but not "CW" — the spelling TciProtocol produces
    for TCI's `cw` and the one a Flex reports — so CW fell through to the USB
    fallback and was demodulated as SSB with the mode indicator reading CW.
    NFM was missing for the same reason.

Verified on a live Hermes-Lite 2 (00:1C:C0:A2:13:DD) with a stdlib TCI client
that shares no code with AetherSDR:
  - handshake: trx_count:1, one slice, one pan, WSJT-X's fields all present
  - RX audio: 187 frames / 382,976 floats in 4 s (previously zero)
  - VFO set echoed in 15–39 ms, against a 2 s client timeout
  - keyed via TCI and held the full 4 s with no spurious unkey
  - TX audio reaches the modulator: forward-power counts peak at 1533 with
    audio vs 0–1 keyed silent, TX FIFO steady at 26–30 with no under/overflow
  - per-mode passbands correct for cw/digu/digl/usb/lsb/am/cwr

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the operator's seat: DIGU on 7.074 put the FT8 signals on the
wrong side of the passband and decoded nothing, while DIGL on the same dial
decoded perfectly. USB and LSB were both fine. A sideband bug that reverses
itself depending on which mode you arrived from is an ordering bug, and it was.

In WDSP the mode does not select the RX sideband — the NBP filter edges do (see
WdspChannel::setFilter). SetRXAMode rebuilds that stage from its own per-mode
notion of the passband, so any filter pushed BEFORE the mode call is discarded
by it. Hl2Backend::setSliceMode pushed the filter first and never re-pushed.

Why it looked mode-dependent: USB<->LSB flips the filter's SIGN, so
SliceModel::normalizeFilterPolarity re-pushed the passband after the mode and
those two were always correct. USB->DIGU does not flip the sign, nothing
re-pushed, and DIGU ran on whatever SetRXAMode had left behind. DIGL is
reachable only via a sign flip, which is why it worked.

Fix: mode FIRST, then passband, re-pushed on EVERY mode set rather than only
when its value changed.

The transmit chain had the matching hole: Hl2TxDsp::setFilter existed with no
caller at all, so TX ran on its construction-time 300..2700 for every mode of
the session. Now pushed on mode change and seeded correctly at connect.

TX and RX turn out to use OPPOSITE passband conventions, which is why they get
separate tables:

  RX (RXANBPSetFreqs)      the SIGN selects the sideband; the mode does not.
  TX (SetTXABandpassFreqs) the MODE selects the sideband; the bandpass is an
                           audio-domain magnitude.

Feeding TX the RX table's signed pairs put LSB and DIGL on the UPPER sideband.
That was caught by extending hl2_txdsp_test to DIGU/DIGL before it shipped —
the pre-existing assertions only ever ran against the struct's default filter,
so the passband's effect on the sideband had never been exercised. The test now
drives a 1 kHz tone through the real modulator with the exact passband the
backend pushes and reads the sideband off the emitted IQ: USB/DIGU and LSB/DIGL
land on opposite wire bins with 77-85 dB suppression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the operator's seat: on 40 m, DIGU at 7.074 put the FT8 traffic
at 7.071 on the panadapter — left of a correctly-drawn passband — and decoded
nothing, while LSB/DIGL at the same dial lined up and decoded perfectly. LSB
transmit even produced real spotted QSOs. A radio that is entirely correct in
the wrong mode is not a mode bug; the whole chain was inverted, coherently.

Hl2RxDsp handed the DEMODULATOR the conjugate of the wire IQ and the SPECTRUM
the raw wire. Both are backwards. Two facts, measured on live hardware against
WWV rather than reasoned from convention:

  1. The HPSDR wire is the conjugate of the analytic convention: a signal ABOVE
     the NCO arrives at a NEGATIVE frequency.
  2. WDSP's RXA, as configured here, selects the OPPOSITE sign to its passband
     bounds — USB with [+150,+3000] passes negative frequencies. Confirmed
     independently by hl2_rxdsp_test and hl2_shift_test.

So the demodulator wants the RAW wire (1 and 2 cancel) and the spectrum, which
has no such quirk, wants the CONJUGATE. They were wired to each other's.

The decisive measurement was forcing the WDSP slice shift to ZERO — the one
geometry where no second error can compensate. With the shift at zero the old
chain had USB hearing signals BELOW the dial and LSB hearing them above, by
100-300x in magnitude, in all four SSB-family modes.

The slice-shift sign is UNCHANGED. It looked like a co-conspirator and is not:
it is derivable once the handedness is settled (the wire puts F at -(F - NCO),
so shift = slice - NCO maps the slice to baseband). It only ever looked wrong
because hl2_shift_test validated it in LSB — the one mode the conjugation bug
made correct. Flipping it was tried and measurably broke off-centre tuning.

Both DSP tests were feeding textbook analytic IQ that no HL2 ever sends, which
is why a mirrored panadapter and an inverted demodulator both passed:

  hl2_rxdsp_test  fed exp(+j.w.t) for a signal above centre; the wire sends
                  exp(-j.w.t). Now generates wire order.
  hl2_shift_test  same, and its "tone 800 Hz BELOW the NCO" was really 800 Hz
                  above. Now generates wire order; assertions unchanged.

Verified on a live Hermes-Lite 2 (00:1C:C0:A2:13:DD), receive only:
  - shift forced to zero: usb/digu hear ABOVE the dial, lsb/digl BELOW,
    100-150x margin (previously inverted in all four)
  - normal off-centre tuning: all four correct, recovered tone exactly 1500 Hz
    on a 1500 Hz offset, confirming the shift sign
  - 20 m panadapter: FT8 renders INSIDE the DIGU passband at 14.074-14.077,
    right of the cursor, confirmed by the operator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The RX handedness bug survived a full session of correct-looking measurements
and is the one most likely to recur verbatim on the next backend that owns its
own DSP. Writing it down is the point of this file.

New §15 covers the whole thing: the demodulator/spectrum conjugation split and
why each consumer needs the opposite convention; why the slice shift is NOT part
of the bug and must not be "fixed"; a symptom-to-cause table (chiefly
"LSB/DIGL work and USB/DIGU do not" meaning coherent inversion rather than a
broken mode); the zero-shift measurement that is the only geometry where
compensating errors cannot cancel; and a six-step bring-up checklist.

§5 gains the two WDSP sideband rules the backend actually depends on: RX selects
the sideband from the PASSBAND EDGES (so mode must be set before the passband,
and the passband re-pushed on every mode set), while RX's RXA passes the
OPPOSITE sign to its bounds — and TX is the mirror image, taking the sideband
from the MODE with a positive audio-domain bandpass. Those opposite conventions
are why Hl2Backend carries two separate passband tables.

§14.6 previously recorded that the receive path "already compensated" for wire
handedness and that transmit merely needed the same correction. That reasoning
was right about the wire and wrong about which stage carries the fix — the
receive-side conjugation was itself the bug. Corrected in place with a pointer
to §15 rather than deleted, since the wrong turn is part of the record.

§14.8 gains two open items surfaced while proving the fix: a consistent ~200 Hz
(≈20 ppm) receive offset at 10 MHz with no calibration knob, and RTTY appearing
in the TCI modulations_list with no HL2 mode mapping, falling through to USB.

Header now points a new reader at §15 and §5 first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jensenpat
jensenpat marked this pull request as ready for review July 26, 2026 15:31
@jensenpat
jensenpat force-pushed the feat/hl2-tci-signaling branch from 2c0a5bb to 790e0c3 Compare July 26, 2026 15:31
@jensenpat
jensenpat requested review from a team as code owners July 26, 2026 15:31
@jensenpat

Copy link
Copy Markdown
Collaborator Author

Post-rebase on-air confirmation — QSO with KK7WAV, 20 m FT8 DIGU

Closing the one gap flagged when this was rebased: the conflict resolution in AudioEngine::feedDaxTxAudio landed on code that had not been exercised on hardware.

Completed QSO with KK7WAV on 20 m FT8 in DIGU, plus 24 PSK Reporter spots, on the rebased binary.

A completed QSO is the stronger of the two results — it is bidirectional. Decoding him exercises the receive chain; him decoding us exercises the transmit chain end to end, through independent equipment, with a full exchange rather than a one-way heard-report. Spot count varies with propagation and time of day and is not a quality metric; a completed exchange is binary and it passed.

This specifically validates the merged path, which is what the rebase touched:

TciServer::onBinaryMessage
  -> AudioEngine::feedDaxTxAudio        (WSPR-active guard, from #4435)
  -> feedDaxTxAudioInternal             (new signature, from #4435)
  -> host-modulation branch             (this PR)
  -> txFinalMonitorPcmReady
  -> RadioModel::submitTxAudio -> Hl2Backend

Both upstream's WSPR beacon refactor and this PR's host-modulation branch survive the merge with the behaviour each intended. One useful consequence of the ordering: on an HL2 a WSPR beacon transmission now also routes through the host modulator, which is correct for a radio that modulates locally — it would otherwise have tried to build VITA-49 packets for a stream that does not exist.

Rebased onto main at 7c43e9e. Suite: 184/184.

…s build

Two review findings on this branch.

TUNE keyed without publishing the raw-TX edge. The branch established that a
non-Flex backend publishes radioTransmittingChanged from its own keying command
(nothing else can, with no interlock status plane) and wired that into
setTransmit() and the moxCommandIssued path — but not tuneCommandIssued, which
also keys: Hl2Backend::setTune() calls setKeying(). So a tune carrier went out
with m_radioTransmitting still false. Three consequences, in descending order of
how much they matter on the air:

  - TciServer's "already transmitting" guard reads isRadioTransmitting(), so a
    TCI client could key ON TOP of a live tune carrier; that client's unkey then
    dropped the key while the backend still believed tune owned it.
  - No trx:true reached TCI clients, so a TCI-driven amplifier never switched to
    TX for a tune carrier — the signal operators most often tune INTO an amp.
  - Waterfall freeze and the TX indicator, both driven off the same edge, stayed
    idle while transmitting.

Covered by a new case in hl2_tci_signaling_test, verified to fail (3 assertions)
with the fix reverted. It drives tune through PttSource::Dax because that is the
one source localPttInterlockMessage() admits with no TX slice assigned, and
TCI/DAX-initiated tune is a real path in its own right. The Flex case gains a
startTune() alongside its setMox()/setTransmit() calls, pinning that Flex still
publishes nothing from a command.

hl2_tci_signaling_test did not compile without Qt6WebSockets. TciServer.h is
`#pragma once` followed immediately by `#ifdef HAVE_WEBSOCKETS`, so it expands
to nothing in such a build, and the Hl2TciSignalingTest friend helper named
TciServer outside any guard — "unknown type name 'TciServer'". Every other TCI
test sits inside `if(Qt6WebSockets_FOUND)`; this target does not, deliberately,
because the rest of the file (transmit edge, command plane, host-modulated TX
audio, per-mode passband) is WebSocket-free and worth running there. So the
guard goes around the helper instead of around the target.

Verified by compiling the translation unit with -DHAVE_WEBSOCKETS stripped from
its real compile line: fails before, clean after. Suite 184/184.

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

Copy link
Copy Markdown
Collaborator Author

Review fixes

Two confirmed findings from a review pass over this branch, both fixed in 7d07f7b.

1. TUNE keyed without publishing the raw-TX edge

This branch establishes that a non-Flex backend publishes radioTransmittingChanged from its own keying command — nothing else can, with no interlock status plane — and wired that into setTransmit() and the moxCommandIssued path. It missed tuneCommandIssued, which also keys: Hl2Backend::setTune() calls setKeying().

So a tune carrier went out with m_radioTransmitting still false:

  • TciServer's "already transmitting" guard reads isRadioTransmitting(), so a TCI client could key on top of a live tune carrier — and that client's unkey then dropped the key while the backend still believed tune owned it.
  • No trx:true reached TCI clients, so a TCI-driven amplifier never switched to TX for a tune carrier — the signal operators most often tune into an amp.
  • Waterfall freeze and the TX indicator, both driven off the same edge, stayed idle while transmitting.

New case in hl2_tci_signaling_test, verified to fail with 3 assertions when the fix is reverted. It drives tune through PttSource::Dax — the one source localPttInterlockMessage() admits with no TX slice assigned, and a real path in its own right. The Flex case gains a startTune() alongside its existing calls, pinning that Flex still publishes nothing from a command.

2. hl2_tci_signaling_test did not compile without Qt6WebSockets

TciServer.h is #pragma once followed immediately by #ifdef HAVE_WEBSOCKETS, so it expands to nothing in such a build — and the Hl2TciSignalingTest friend helper named TciServer outside any guard: unknown type name 'TciServer'.

Every other TCI test sits inside if(Qt6WebSockets_FOUND). This target deliberately does not, because the rest of the file — transmit edge, command plane, host-modulated TX audio, per-mode passband — is WebSocket-free and worth running in that configuration. So the guard went around the helper rather than the target.

Verified by compiling the translation unit with -DHAVE_WEBSOCKETS stripped from its real compile line: fails before, clean after.

Suite 184/184.

Noted, not fixed

defaultPassbandForMode returns ±40 kHz for WBFM/WFM, which exceeds Nyquist at the HL2's default 48 kHz (and at 96 kHz) — only 384 kHz can represent it. WdspChannel::setFilter validates only low < high, so it passes through to RXANBPSetFreqs. Left alone: I am not confident enough in WDSP's behaviour for an out-of-range NBP passband to pick a correct clamp here, and WBFM on an HL2 is not a mode this PR set out to support. Flagging it rather than guessing.

ten9876 added 3 commits July 26, 2026 10:01
Two review findings on the HL2 TCI signaling work, both the same shape as
the ones it already fixed: a Flex-only assumption that fails silently on a
seam backend.

VFO B / split wedged TCI keying permanently. tuneSliceAndConfirm() learned
that sendCmdPublic() is swallowed on a backend with no Flex command plane,
but createTxSliceForVfoB() and promoteTxSliceAndContinue() did not. Both
open a route transition around a command whose completion callback then
never runs, so m_routeTransitionInFlight stayed true forever — and
handleTrxRequest() defers every trx:true while one is in flight. WSJT-X
with Split = Rig/Fake It sends split_enable:0,true; before it transmits, a
single-slice HL2 resolves that to RouteAction::Create, and from that moment
the rig showed connected and could not key again for the rest of the
session. The capacity check ahead of the create does not catch it:
maxSlices() is the model-string-derived Flex estimate, so a one-slice HL2
looks like it has room. Refuse the route instead, which is also the honest
answer — reportVfoBRouteFailure() withdraws the split and returns the
authoritative channel-1 VFO, so WSJT-X falls back to single-VFO operation.
promoteTxSliceAndContinue() now always answers rather than going silent;
the already-TX slice every HL2 key uses still short-circuits above it.

MOX and TUNE published a transmit that never happened. setTransmit()
refuses keying when backendCapabilities().canTransmit is false; the
mox/tuneCommandIssued paths had no such test, so with the HL2 transmit gate
closed Hl2Backend::setKeying() logged "key refused" and returned while this
side still flipped m_radioTransmitting. TciServer broadcast trx:...,true;
for nothing on the air, and its "already transmitting" guard then rejected
the next genuine TCI key. Gated in two places on purpose, matching the
backend's own idiom: localPttInterlockMessage() refuses at the preflight so
requestPttOn() (MOX, TCI hardware PTT) and startTune() never build the
optimistic state, and the seam handlers refuse as a backstop and roll back
what was already latched.

Tests: 12 new assertions in hl2_tci_signaling_test, each verified to fail
without its fix. Also restates hl2_shift_test's runTone() contract — the
argument is wire-signed, the opposite of the NCO offset its doc claimed.
# Conflicts:
#	HERMES.md
#	src/core/backends/hl2/Hl2RxDsp.cpp
#	src/models/RadioModel.h
Integrating aethersdr#4470 (operator-controllable span and display rate) with this
branch's receive-handedness fix needed two things beyond the textual merge,
and both were silent failures rather than conflicts.

The span shaper feeds Hl2Spectrum on BOTH branches — process() when a frame
is due, accumulate() when it is skipped — so conjugating only the process()
call would have left every skipped block mirrored inside the window that
the next displayed frame is built from. Conjugate once, ahead of the branch.

hl2_rxdsp_rate_test arrived on main generating exp(+j.2.pi.f.t), the
textbook analytic tone no HL2 ever puts on the wire. Against the corrected
chain — raw wire to the demodulator, USB [150,3000] selecting the opposite
sign — that tone is out of passband, and the test went silent at all four
rates. Same stimulus correction this branch already applied to
hl2_rxdsp_test and hl2_shift_test; peak recovers to 3.6 at every rate,
0.0 dB spread. The test was only ever passing because the chain it ran
against inverted every sideband.

Also renumbers this branch's HERMES section to §16, since aethersdr#4470's landed
first and took §15.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. Full pass over every hunk plus the surrounding call graphs in TciServer, RadioModel, AudioEngine, Hl2Backend, Hl2RxDsp/Hl2TxDsp, SliceModel and TransmitModel. Two defects found; both fixed on this branch (4917ba4, e774b22) with tests verified to fail without their fix.

Fixed here

TciServer — VFO B / split wedged TCI keying permanently. tuneSliceAndConfirm() learned that sendCmdPublic() is swallowed on a backend with no Flex command plane, but createTxSliceForVfoB() and promoteTxSliceAndContinue() did not. Both open a route transition around a command whose completion callback then never runs, so m_routeTransitionInFlight stayed true forever — and handleTrxRequest() defers every trx:true while one is in flight. WSJT-X with Split = Rig/Fake It sends split_enable:0,true; before it transmits, a single-slice HL2 resolves that to RouteAction::Create, and from that moment the rig showed connected and could not key again for the rest of the session. Worth noting the capacity check ahead of the create does not catch it: maxSlices() is the model-string-derived Flex estimate (2 by default), so a one-slice HL2 looks like it has room. Now refused before the transition opens, which is also the honest answer — reportVfoBRouteFailure() withdraws the split and returns the authoritative channel-1 VFO, so WSJT-X falls back to single-VFO operation instead of waiting. promoteTxSliceAndContinue() now always answers rather than going silent; the already-TX slice every HL2 key actually uses still short-circuits above it.

RadioModel — MOX and TUNE published a transmit that never happened. setTransmit() refuses keying when backendCapabilities().canTransmit is false; the mox/tuneCommandIssued paths had no such test. With the HL2 transmit gate closed, Hl2Backend::setKeying() logged "key refused" and returned while this side still flipped m_radioTransmitting — so TciServer broadcast trx:…,true; for nothing on the air, and its "already transmitting" guard then rejected the next genuine TCI key. Also reachable via the TCI hardware-PTT path (requestPttOn(TciHardware)setMox). Gated in two places on purpose, matching this backend's own idiom: localPttInterlockMessage() refuses at the preflight so requestPttOn() and startTune() never build the optimistic state, and the seam handlers refuse as a backstop and roll back what was already latched.

Merge integration with #4470. Two silent failures rather than conflicts. The span shaper feeds Hl2Spectrum on both branches — process() when a frame is due, accumulate() when it is skipped — so conjugating only the process() call would have left every skipped block mirrored inside the window the next displayed frame is built from; the conjugation now happens once, ahead of the branch. And hl2_rxdsp_rate_test arrived on main generating exp(+j·2π·f·t), the textbook analytic tone no HL2 ever puts on the wire: against the corrected chain it is out of passband and the test went silent at all four rates. Same stimulus correction this branch already applied to hl2_rxdsp_test/hl2_shift_test — peak recovers to 3.6 at every rate, 0.0 dB spread. That test was only passing because the chain it ran against inverted every sideband. HERMES section renumbered to §16 since #4470 landed first and took §15.

Checked and cleared

The RX handedness swap (m_conjugated for spectrum, raw wire for demod) is internally consistent with the updated stimuli and with the TX-side wireUpper expectations in hl2_txdsp_test. The RX/TX passband tables are sign-correct against SliceModel::normalizeFilterPolarity — CW/CWU/CWL fall in no polarity family so the positive CW pair survives, AM/FM/DSB straddle. The feedDaxTxAudio host-modulation branch converts float32→int16 correctly, including clamp order and the −1.0 edge, and the 24 kHz contract holds end to end (TciServer resamples 48k→24k, DEFAULT_SAMPLE_RATE is 24000, Hl2Backend::submitTxAudio rejects anything else). The new backendAudioFrameReady → onDaxAudioReady(1, …) connection is inside #ifdef HAVE_WEBSOCKETS, wireCatPorts() is called once so it cannot duplicate, and the channel→trx fallback resolves 1→0. m_txAudioPrepared resets per TX session, so hostModulatingBackend() is re-evaluated after a family swap. ensureDaxForTci's !panStream() guard is a proxy for the new capability predicate but has no reachable failure on Flex.

Also restated hl2_shift_test's runTone() contract: the argument is wire-signed, the opposite of the NCO offset its doc claimed, which is precisely the trap §16 exists to document.

Verification

Full local build, 185/186 tests pass. The one failure — cross_needle_meter_test, "SWR label placement cache is keyed by the rendered font" — reproduces identically on pristine origin/main in this environment and is unrelated to this PR.

@ten9876
ten9876 enabled auto-merge (squash) July 26, 2026 17:15
@ten9876
ten9876 merged commit 9e82881 into aethersdr:main Jul 26, 2026
7 checks passed
jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jul 26, 2026
…gnostic

Rebased onto PR aethersdr#4471, which already carried the transmit work by content and
adds the receive-handedness fixes this needs. Generalises txcert into a phased
runner covering both directions.

ONE VERB, NOT TWO, and the reason is not tidiness. The checks worth the most are
CROSS-CONSUMER, and two separate tools structurally cannot make them:

  panadapter      vs demodulator      (15.5 — each had the other's convention)
  RX handedness   vs TX handedness    (one wire fact, two users)
  demodulated audio vs transmitted    (14.6)

A combined runner states the wire handedness ONCE and shows every consumer
agreeing or not. Split in two, you get two self-consistent reports that both
pass — which is precisely how this radio transmitted on the wrong sideband for a
fortnight.

RECEIVE RUNS FIRST when both phases are selected. The wire's handedness is one
fact both directions consume, so a transmit result read before the receive
convention is settled is a result about an unknown quantity (15.6).

New receive stages, each a transcription of a specific failure:

  mode-map                    every mode the app can emit survives a round trip.
                              Plain "CW" was absent while "CWU" was present, so CW
                              was demodulated as SSB with the indicator correct;
                              "RTTY" is advertised over TCI with no mapping to
                              this day. An unmapped mode does not fail, it becomes
                              USB. (15.7)
  zero-shift                  forces the NCO to re-centre so the slice shift is
                              exactly zero. Two compensating errors cancel at any
                              other geometry, so measurements taken at normal
                              off-centre tuning prove nothing. (15.4)
  rx-sidebands                all FOUR SSB-family modes against a known off-centre
                              carrier. A single-mode sideband test proves nothing:
                              hl2_shift_test validated in LSB, the one mode the
                              inversion made correct, and passed throughout. (15.3)
  consumer-agreement          panadapter versus demodulator. Not yet automated —
                              it needs a spectrum tap through the seam — so it is
                              reported as an operator check rather than quietly
                              omitted. It is the check that found the bug. (15.5)
  passband-after-mode-change  SetRXAMode discards a passband applied before it.
                              CW then DIGU is the ordering that exposed it: a
                              500 Hz window survived into a wide mode and the
                              decoder saw nothing. (15.7)

WWV is the default receive reference. It is free, always on, exactly known, and
comes from a source that is not us — the one external reference available
without a second radio, which is what 15.4 used to settle handedness.

The receive phase does NOT require transmit permission. Gating it would put the
first thing a new radio needs behind a permission nobody would have granted yet;
only the keying phases are gated, on the same rail as every other TX verb.

Still deliberately diagnostic — no pass/fail. Thresholds meaningful for one radio
are guesses for the next, and the report says so. It ends with what it cannot
determine, now including the two receive items: panadapter-versus-demodulator
off-centre, and third-party spots in the mode under test. The receive bring-up
ended with 63 spots on 14.074 DIGU, the first evidence that could not have come
from a self-consistent loop.

Suite: 184/184.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
K5PTB pushed a commit to K5PTB/AetherSDR that referenced this pull request Jul 27, 2026
…r#4500, aethersdr#4493)

WSJT-X lost all rig control in v26.7.4: band changes failed, the dial read
the wrong frequency, and transmissions went out of band.

aethersdr#4407 replaced the model-driven tune with a raw `slice tune` written at the
connection, using the radio's command reply as a barrier to read the settled
frequency back out of SliceModel. Both halves of that are wrong on a Flex.
The raw command bypasses SliceModel, so nothing updates m_frequency; and the
radio answers `slice tune` with no RF_frequency status either (measured by
@milenjb in aethersdr#4493: 89 tunes, 0 frequency statuses in one session). The
read-back could therefore only ever observe the PRE-TUNE value, and every
confirmation echoed the frequency the slice had already left.

WSJT-X's do_frequency() waits on that echo. It concluded the radio had not
moved and reported rig-control failure on every band change — while the radio
was in fact on the new band, which is how transmissions ended up out of band
and why this is a licence-compliance bug rather than a cosmetic one.

aethersdr#4407 also added a short-circuit that confirmed from the model WITHOUT
commanding the radio whenever the request matched what the model held. On its
own that is harmless. Downstream of a model that no longer tracks the radio it
is not: once the two diverged, a client asking for the frequency it had just
been told got a false confirmation and the radio never moved at all. That is
the second, worse divergence, and it is gone with the model authoritative
again — a no-op now falls through to the setter, which ignores it internally
and is still acknowledged.

Tune through the model on every command plane. The setter IS the command path,
not an addition to it: setFrequency() emits the byte-identical
"slice tune <id> <mhz> autopan=0" this open-coded, and additionally updates the
model, honours a locked slice, emits frequencyChanged, and routes through the
TX-inhibit-guarded slice sink that the raw sendCmdPublic bypassed. There is no
second command and no double-tune.

This also collapses the Flex/seam split that aethersdr#4471 introduced: the non-Flex
path already did exactly this, so only Flex was carrying the broken mechanism.
One path now, and tuneSliceAndConfirm is synchronous, so a caller's route
transition closes after the tune has really been taken rather than before.

The confirmation is sent unconditionally even though a successful tune also
reaches clients via frequencyChanged → broadcastSliceFrequencies(). That
duplicates the channel-0 vfo: frame, which is idempotent; the alternative
failure — a client left with no confirmation — hangs WSJT-X for its full
rig-control timeout, and channel 1 is not covered by that automatic path
unless the routing state happens to be bound.

Tests: three cases in tci_server_review_test, asserted on the wire via
tciMessage rather than only on the model, since the echo is what clients
consume. The accumulating-tune case is @milenjb's Stream Deck encoder
scenario. Verified to fail without the fix — "model did not take the tune
(wanted 14224000, model holds 14225000)".

Not fixed here, and worth its own issue: the radio never confirming a tune at
all predates aethersdr#4407. The optimistic model update is what masks it, which is
exactly why removing that update broke this much.
K5PTB pushed a commit to K5PTB/AetherSDR that referenced this pull request Jul 27, 2026
…r#4500, aethersdr#4493)

WSJT-X lost all rig control in v26.7.4: band changes failed, the dial read
the wrong frequency, and transmissions went out of band.

aethersdr#4407 replaced the model-driven tune with a raw `slice tune` written at the
connection, using the radio's command reply as a barrier to read the settled
frequency back out of SliceModel. Both halves of that are wrong on a Flex.
The raw command bypasses SliceModel, so nothing updates m_frequency; and the
radio answers `slice tune` with no RF_frequency status either (measured by
@milenjb in aethersdr#4493: 89 tunes, 0 frequency statuses in one session). The
read-back could therefore only ever observe the PRE-TUNE value, and every
confirmation echoed the frequency the slice had already left.

WSJT-X's do_frequency() waits on that echo. It concluded the radio had not
moved and reported rig-control failure on every band change — while the radio
was in fact on the new band, which is how transmissions ended up out of band
and why this is a licence-compliance bug rather than a cosmetic one.

aethersdr#4407 also added a short-circuit that confirmed from the model WITHOUT
commanding the radio whenever the request matched what the model held. On its
own that is harmless. Downstream of a model that no longer tracks the radio it
is not: once the two diverged, a client asking for the frequency it had just
been told got a false confirmation and the radio never moved at all. That is
the second, worse divergence, and it is gone with the model authoritative
again — a no-op now falls through to the setter, which ignores it internally
and is still acknowledged.

Tune through the model on every command plane. The setter IS the command path,
not an addition to it: setFrequency() emits the byte-identical
"slice tune <id> <mhz> autopan=0" this open-coded, and additionally updates the
model, honours a locked slice, emits frequencyChanged, and routes through the
TX-inhibit-guarded slice sink that the raw sendCmdPublic bypassed. There is no
second command and no double-tune.

This also collapses the Flex/seam split that aethersdr#4471 introduced: the non-Flex
path already did exactly this, so only Flex was carrying the broken mechanism.
One path now, and tuneSliceAndConfirm is synchronous, so a caller's route
transition closes after the tune has really been taken rather than before.

The confirmation is sent unconditionally even though a successful tune also
reaches clients via frequencyChanged → broadcastSliceFrequencies(). That
duplicates the channel-0 vfo: frame, which is idempotent; the alternative
failure — a client left with no confirmation — hangs WSJT-X for its full
rig-control timeout, and channel 1 is not covered by that automatic path
unless the routing state happens to be bound.

Tests: three cases in tci_server_review_test, asserted on the wire via
tciMessage rather than only on the model, since the echo is what clients
consume. The accumulating-tune case is @milenjb's Stream Deck encoder
scenario. Verified to fail without the fix — "model did not take the tune
(wanted 14224000, model holds 14225000)".

Not fixed here, and worth its own issue: the radio never confirming a tune at
all predates aethersdr#4407. The optimistic model update is what masks it, which is
exactly why removing that update broke this much.
jensenpat pushed a commit that referenced this pull request Jul 27, 2026
…4493) (#4501)

Fixes #4500. Fixes #4493.

**Release-blocking.** WSJT-X lost all rig control in v26.7.4 — band
changes fail, the dial reads the wrong frequency, and transmissions go
out of band. Two users hit it independently within hours of the release,
from opposite directions (WSJT-X and a Stream Deck encoder).

## Root cause

#4407 replaced the model-driven tune with a raw `slice tune` written at
the connection, using the radio's command reply as a barrier to read the
settled frequency back out of `SliceModel`. Both halves are wrong on a
Flex:

- the raw command **bypasses `SliceModel`**, so nothing updates
`m_frequency`;
- the radio answers `slice tune` with **no `RF_frequency` status**
either — measured by @milenjb in #4493: 89 tunes, 0 frequency statuses
in one session.

So the read-back could only ever observe the **pre-tune** value, and
every confirmation echoed the frequency the slice had already left.

WSJT-X's `do_frequency()` waits on that echo. It concluded the radio had
not moved and reported rig-control failure on every band change — while
the radio *was* on the new band. That mismatch is why this produces
out-of-band transmissions rather than just a wrong display, and why it
is labelled `safety`.

### The second divergence

#4407 also added a short-circuit that confirmed from the model **without
commanding the radio** when the request matched what the model held.
Alone, harmless. Downstream of a model that no longer tracks the radio,
not: once the two diverged, a client asking for the frequency it had
just been told got a false confirmation and the radio never moved at
all.

## The fix

Tune through the model on every command plane.

**The setter is the command path, not an addition to it.**
`SliceModel::setFrequency()` emits the byte-identical `slice tune <id>
<mhz> autopan=0` this open-coded — there is no second command and no
double-tune. It additionally updates the model, honours a locked slice,
emits `frequencyChanged`, and routes through the TX-inhibit-guarded
slice sink that the raw `sendCmdPublic` bypassed.

Net effect on `TciServer.cpp` is **47 added, 58 removed** — this is a
simplification. It also collapses the Flex/seam split #4471 introduced:
the non-Flex path was already doing exactly this, so only Flex carried
the broken mechanism. `tuneSliceAndConfirm` is now synchronous, so a
caller's route transition closes *after* the tune has been taken rather
than before.

The no-op short-circuit is removed rather than kept. Its stated purpose
— acknowledging a repeat so WSJT-X cannot time out during startup
convergence — survives, because a no-op falls through to the setter
(which ignores it internally via `qFuzzyCompare`) and is still
confirmed. There is a test pinning that specifically.

### On the duplicate broadcast

The confirmation is sent unconditionally even though a successful tune
also reaches clients via `frequencyChanged` →
`broadcastSliceFrequencies()`. That duplicates the channel-0 `vfo:`
frame.

Deliberate: a duplicate is idempotent, whereas the opposite failure — a
client left with **no** confirmation — hangs WSJT-X for its full
rig-control timeout. Channel 1 is not covered by that automatic path at
all unless the routing state happens to be bound, so suppressing this
would make split confirmations depend on unrelated state. Documented at
the call site.

## Testing

Three cases in `tci_server_review_test`, asserted **on the wire** via
`tciMessage` rather than only on the model, because the echo is what
clients actually consume:

- **accumulating tune** — @milenjb's nine-detent encoder scenario; each
confirmation must carry the frequency just reached, and the stale value
must not also go out.
- **refused tune** — a locked slice must be confirmed with what the
model *holds*, never with what the client asked for.
- **no-op** — must still be acknowledged rather than met with silence
(the behaviour the removed short-circuit was protecting).

Verified to fail without the fix:

```
step 0: model did not take the tune (wanted 14224000, model holds 14225000)
FAIL  vfo: SET confirms the frequency reached (#4500/#4493)
FAIL  vfo: SET confirms the truth when the tune is refused
```

Full suite **190/191**. The single failure is `cross_needle_meter_test`
("SWR label placement cache is keyed by the rendered font"), which
reproduces identically on pristine `origin/main` in a separate worktree
— a local font-environment issue, unrelated.

## Not fixed here

**The radio never confirming a tune at all predates #4407.** The
optimistic model update is what masks it — which is precisely why
removing that update broke this much. This PR restores the mask; the
underlying gap wants its own issue, and #4220's TCI conformance RFC is
the natural home for it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jul 28, 2026
…-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>
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.

2 participants

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