Skip to content

modem: show the waterfall while transmitting - #175

Merged
rafael2k merged 1 commit into
mercuryv2from
tx-waterfall
Aug 13, 2026
Merged

modem: show the waterfall while transmitting#175
rafael2k merged 1 commit into
mercuryv2from
tx-waterfall

Conversation

@rafael2k

Copy link
Copy Markdown
Contributor

Every radio shows you your own signal on transmit — an IC-7300 or FT-710 does.
Mercury froze the display on key-up, because the spectrum slot was fed only
from the capture path.

Approach

Feed the same slot from the transmitted burst. Same slot, same lock, same
sequence counter
as RX, so the publisher thread and the on-the-wire spectrum
frame need no change at all. Only one writer is ever active — the link is half
duplex, so RX decode and TX modulation never overlap — and the FFT runs inline
under the lock exactly as it already does for RX.

Two details decide whether this actually looks like a waterfall:

  • Pacing. Publishing once per burst would paint a single line every 3.7 s
    on DATAC16. Instead it publishes from inside the drain loop that
    send_modulated_data already runs while the burst plays out, indexed by
    elapsed playout. The ring is filled far faster than it drains, so drain
    progress — not write progress — is what paces the display.
  • Frequency axis. Samples are taken at modem rate, which is what
    g_spectrum_sample_rate already reports, so the axis matches RX and the
    display does not jump scale on key-up.

Honours the existing waterfall switch: with the UI waterfall off there is no
publisher thread, so the modem skips the work rather than computing frames
nobody consumes.

The bug worth recording

g_spectrum_stats is opened lazily, and TX now gets there first — mercury
sends CALL before it has decoded anything. Omitting modem_stats_open() ran
the first burst of every connect over an unopened MODEM_STATS and hung the
connect at ~10 s.

Only the integration suite caught it. Build was clean and the unit suite
passed, because neither transmits. Worth remembering for anything touching the
TX path.

Two theories I chased first were both wrong and are recorded here so nobody
re-runs them: it is not a lock-order inversion (the instance lock is released
at modem.c:1116, well before the drain loop at 1160+, so the publish holds no
modem locks), and "don't compute an FFT under the spectrum lock" is advice the
RX path already ignores successfully at modem.c:2229.

Gate

unit All Tests Passed
integration 245.5 s vs a 245–246 s baseline
TSan 1 warning — the pre-existing hamlib debugmsgsave2; no mercury frames, 0 lock-order inversions

Caveat on TSan: that run is -x null idle RX, so it does not exercise the new
TX path. The ASan+UBSan CI job runs an integration session, which does.

Not yet verified visually — the waterfall should be watched on a real UI during
a transmit to confirm it scrolls and the scale is stable across key-up.

Every radio shows you your own signal on transmit; mercury froze the display
on key-up, because the spectrum slot was fed only from the capture path.

Feed it from the transmitted burst as well.  Same slot, same lock, same
sequence counter the RX path uses, so the publisher thread and the on-the-wire
spectrum frame need no change whatsoever.  Only one writer is ever active --
the link is half duplex, so RX decode and TX modulation never overlap -- and
the FFT runs inline under the lock exactly as it already does for RX.

Two details that decide whether this looks right:

  - Publishing once per burst would paint a single line every 3.7 s on
    DATAC16.  Instead it publishes from inside the drain loop that
    send_modulated_data already runs while the burst plays out, indexed by
    elapsed playout.  The ring is filled far faster than it drains, so drain
    progress -- not write progress -- is what paces the display.

  - Samples are taken at modem rate, which is what g_spectrum_sample_rate
    already reports, so the frequency axis matches RX and the display does not
    jump scale on key-up.

Gated by g_spectrum_enabled, the switch the RX FFT already uses, so the UI
checkbox and -W turn off both directions at once: one waterfall control, one
setter.  Unlike RX there is no second consumer to keep it alive -- the
channel-busy detector shares the RX FFT, but occupancy means nothing while we
are the ones occupying the channel.

The stats struct is opened lazily and TX now gets there first -- mercury sends
CALL before it has decoded anything -- so the open is done here too.  Omitting
it ran the first burst of every connect over an unopened MODEM_STATS, which
hung the connect; caught by the integration suite, which is the only gate that
transmits.

Gate: unit suite green; integration 245.1 s against a 245-246 s baseline;
mercury and the embedded fyne UI both build, go vet clean.
@rafael2k
rafael2k merged commit a444cfc into mercuryv2 Aug 13, 2026
8 checks passed
@rafael2k
rafael2k deleted the tx-waterfall branch August 13, 2026 08:19
themcfarland pushed a commit to themcfarland/mercury that referenced this pull request Aug 15, 2026
The TX waterfall (Rhizomatica#175) replaced the single usleep(playback_duration) that
held PTT for a burst with a loop of usleep(50 ms) slices, so the waterfall
scrolls during the burst instead of after it.  usleep can return late but
never early, so each slice's overshoot accumulates over the ~75-90 slices in
a burst.

On Linux that costs ~7 ms and nobody notices.  On Windows, whose default
scheduler tick is ~15.6 ms, a 4.41 s DATAC15 burst keys for 5.5 s -- 1.1 s of
dead carrier after the audio has already finished.  The IRS answers 700 ms
after it decodes, so on air every single ACK landed inside our own tail and
was never heard.  Measured on the 2026-08-13 station pair: the Linux station
decoded 19/20 of the peer's bursts, the Windows station 3/17, and the link
retransmitted the same 3-byte frame to ack_timeout for six minutes.

The excess is proportional to burst length, which is what identifies it:

  frame     modulation   Linux station   Windows station
  DATAC16      3.74 s     3.74-3.75 s     4.76-4.83 s   (+1.03 s)
  DATAC15      4.41 s     4.41 s          5.57-5.60 s   (+1.17 s)

and it reproduces exactly under a simulated 15.6 ms tick (predicted 4.78 /
5.63 s including tail and tx_delay).

Re-derive each sleep from elapsed time against an absolute deadline; the
total error is then bounded by one tick however coarse the tick is (12 ms at
15.6 ms, vs 1104 ms before).  A host that falls behind catches up by not
sleeping rather than by keying for longer.

The pacing arithmetic moves to modem/tx_pacing.h as a pure function so the
property is testable without a sound card, a radio or a real clock:
test_tx_pacing drives it through a simulated coarse timer and asserts the
tail stays far under the ACK guard.  Verified to fail (2/4) against the
relative-sleep version.

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.

1 participant