Skip to content

fix(audio): dax_rx no longer overwrites the speaker stream's id (#34) - #43

Merged
nigelfenton merged 1 commit into
mainfrom
fix/dax-overwrites-speaker-stream-34
Sep 7, 2026
Merged

fix(audio): dax_rx no longer overwrites the speaker stream's id (#34)#43
nigelfenton merged 1 commit into
mainfrom
fix/dax-overwrites-speaker-stream-34

Conversation

@nigelfenton

Copy link
Copy Markdown
Owner

Fixes #34.

What was actually wrong

The issue hypothesised contention — two consumers draining one _audio_q. That is not what happens; there is only ever one consumer. Both stream create branches assigned the same field:

remote_audio_rx -> self.audio_stream_id = sid    # engine.py:1484
dax_rx          -> self.audio_stream_id = sid    # engine.py:1506

and the audio thread addressed every frame to that single value (:2295).

So arming dax_rx did not add a consumer — it overwrote the speaker's stream id. From that moment every frame went to DAX and remote_audio_rx received nothing. AE was not losing a race; WSJT-X was simply the only addressee left. That is exactly the reported asymmetry: WSJT-X healthy, AE starved.

stream remove had the mirror defect — it compared against the one id, so removing DAX stopped the audio thread outright.

The fix

  • A stream id per type (audio_streams: {type -> sid}).
  • Each generated frame is emitted to every registered stream, from one get_audio() call. Calling it per stream would pop the single _audio_q twice and starve the demod — i.e. it would make the issue's original hypothesis come true.
  • Per-stream VITA sequence counters. A shared counter advances once per frame, so with two streams AE would see 1-in-2 gaps on both as soon as the second is armed.
  • stream remove drops only the id named, clears dax_channel only for DAX, and stops the audio thread only when nothing is left.
  • audio_stream_id is kept as the most-recently-registered stream (single-stream path, logging) and now falls back to a survivor rather than to None.

How it is proven

By mutation, not by a green run. Reverting _Streams to the old single-id behaviour makes the new suite fail 4 checks and exit 1 — on precisely the right ones:

[FAIL] THE BUG: the speaker is STILL registered   got 1, want 2
[FAIL] speaker is a target                        got False, want True

The suite covers: DAX not evicting the speaker; one frame reaching both; per-stream counters not skipping; removing DAX leaving the speaker registered and running; removing the last stream doing stop it; remove-then-rearm on a different channel; and an unknown id being ignored.

Registered in the hand-maintained CI list in tests.yml — a new stdlib suite is silently skipped otherwise. Full stdlib suite: 24/24.

What this does NOT do

Not verified on hardware. The static defect is certain, but the live repro (Pi5 gate + Radioberry + WSJT-X on 14.074) has not been re-run. Worth doing before merge — the gate is available.

The flattened noise floor is a separate symptom and is not fixed here. _latest (panadapter FFT, adapters/hpsdr/adapter.py:326) and _audio_q (demod, :327) are independent; get_audio() pops _audio_q (:513-514) and never touches _latest, so audio routing cannot flatten the FFT. That half needs its own investigation, and I would rather leave #34's second symptom open than let this PR imply it is solved.

Also unverified: whether #34 still reproduced at all before this change. c8e79b0 ("bound the demodulator's IQ backlog in time, not blocks") landed in this path after the issue was filed, among ~78 commits between v0.3.0 and v0.5.1.

🤖 Generated with Claude Code

Starting WSJT-X collapsed AetherSDR's own reception while WSJT-X stayed
healthy. The issue hypothesised contention -- two consumers draining one
`_audio_q`. It was not that. There was only ever one consumer.

Both `stream create` branches assigned the same field:

    remote_audio_rx -> self.audio_stream_id = sid    (engine.py:1484)
    dax_rx          -> self.audio_stream_id = sid    (engine.py:1506)

and the audio thread addressed every frame to that single value (:2295). So
arming dax_rx did not ADD a consumer -- it OVERWROTE the speaker's id. From
that moment every frame went to the DAX stream and remote_audio_rx received
nothing. AE went deaf; WSJT-X was not winning a race, it was the only
addressee left. `stream remove` had the mirror defect: removing DAX matched
the one id and stopped the audio thread outright.

Fix: keep a stream id PER TYPE (`audio_streams`), and emit each generated
frame to every registered stream from ONE get_audio() call. Calling
get_audio() per stream would pop the single _audio_q twice and starve the
demod -- i.e. it would make the issue's original hypothesis come true. Each
stream carries its own VITA sequence counter; a shared one makes AE see
1-in-N gaps on every stream as soon as a second is armed. `stream remove`
now drops only the id named and stops the thread only when nothing is left.

`audio_stream_id` is kept as the most-recently-registered stream for the
single-stream path and for logging, and falls back to a surviving stream
rather than to None.

Test proven by mutation, not by a green run: reverting _Streams to the old
single-id behaviour fails 4 checks and exits 1 ("the speaker is STILL
registered", "speaker is a target"). Registered in the hand-maintained CI
list -- otherwise it is silently skipped. Full stdlib suite: 24/24.

NOT verified on hardware. The flattened AE noise floor reported in #34 is a
SEPARATE symptom and is not addressed here: the panadapter FFT reads
`_latest` (adapter.py:326) while audio pops `_audio_q` (:513), so audio
routing cannot flatten the FFT. That half needs its own investigation.

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

Copy link
Copy Markdown
Owner Author

Verified on hardware — the "not verified" caveat in the PR body is now retired

Run on the Pi5 gate (10.0.0.128), RTL-SDR (RTL2838) via the soapy adapter, --rx-only, on a spare port so the live IC-9700 service was never touched. The defect is in core/engine.py above the adapter layer, so any adapter reproduces it — no Radioberry needed.

Rather than drive AE and WSJT-X by hand, the probe speaks the Flex protocol directly and counts VITA audio packets per stream id, which measures the thing that actually matters: which stream is being addressed.

Before the fix — gate at 5fa3d0c (v0.5.0, current main)

PHASE 1: speaker only
  stream 0x48000010 : 1030 packets   (remote_audio_rx)

  stream create type=dax_rx dax_channel=1  ->  0x48000040

PHASE 2: DAX armed
  stream 0x48000040 : 1031 packets   (dax_rx)
                                      <- 0x48000010 ABSENT

speaker before DAX: 1030
speaker after  DAX: 0
dax    after  DAX: 1031

Reproduced, and it is not degradation — it is a clean handover. The speaker stream goes to exactly zero the instant DAX is armed, and DAX picks up the identical packet rate. AE is not losing a race; it has stopped being addressed at all.

That also settles the open question from the analysis: #34 does still reproduce on current main, notwithstanding c8e79b0 and the other ~78 commits since it was filed.

After the fix — same gate, same probe, this branch

PHASE 1: speaker only
  stream 0x48000010 :  1030 packets

PHASE 2: DAX armed
  stream 0x48000010 :   985 packets   <- still receiving
  stream 0x48000040 :   984 packets

Both streams receive concurrently.

The ~4% dip (1030 → 985) is expected and accounted for: the loop now issues two sendto calls per frame instead of one, so slightly more of each interval goes on I/O. The two streams stay within one packet of each other, which is the per-stream sequence counters doing their job — a shared counter would have shown one stream at roughly half the other's count.

Cleanup

Test gate stopped; /home/pi/gate restored to 5fa3d0c with a clean tree (audio_streams refs back to 0); aether-gate-9700.service and aether-gate-setup.service both still active throughout — they use the Icom adapter and a different port, so nothing live was disturbed. No transmit at any point.

Still not addressed

The flattened noise floor half of #34 remains open and is deliberately out of scope here — the FFT reads _latest while audio pops _audio_q, so audio routing cannot flatten it. That needs its own investigation with AE actually rendering, which this packet-level probe cannot see.

@nigelfenton
nigelfenton merged commit 3afac7b into main Sep 7, 2026
11 checks passed
@nigelfenton
nigelfenton deleted the fix/dax-overwrites-speaker-stream-34 branch September 7, 2026 04:45
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.

Starting WSJT-X (dax_rx) collapses AE decode + flattens AE noise floor — audio/demod contention

1 participant