Skip to content

feat: AudioBroadcaster TX on the neutral surface (MOR-544, AudioTransport 11/12)#1748

Merged
morozsm merged 1 commit into
mainfrom
codex/mor-544-broadcaster-neutral-tx
Jun 9, 2026
Merged

feat: AudioBroadcaster TX on the neutral surface (MOR-544, AudioTransport 11/12)#1748
morozsm merged 1 commit into
mainfrom
codex/mor-544-broadcaster-neutral-tx

Conversation

@morozsm

@morozsm morozsm commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Step 11/12 of the MOR-532 AudioTransport epic (Linear MOR-544). Moves the web audio handler's TX path onto the codec/transport-neutral AudioTransport surface, with explicit legacy fallback for non-AudioTransport radios. RX side was already neutral; AudioFftScope untouched.

Changes (src/rigplane/web/handlers/audio.py)

  • TX start/stop: audio_start/audio_stop direction=tx now call radio.start_tx() / radio.stop_tx() when the radio exposes the neutral surface (detected via getattr, same pattern as the MOR-543 poller migration). One explicit legacy helper _legacy_tx_lifecycle("start"|"stop") preserves the previous per-codec start_audio_tx_pcm(sample_rate=...)/start_audio_tx_opus() branching.
  • TX push: all radio pushes in _handle_tx_audio route through radio.push_tx(...) via _push_tx(data, legacy_method=...); browser-codec branching (PCM vs Opus from the browser) and transcode decisions still key off _tx_codec().
  • _tx_codec() ordering: first-class audio_tx_codec property → audio_stream_contract.tx_codecaudio_codec. (For FTX-1 this keeps selecting PCM either way; for Icom LAN the property reads the same contract.)
  • _tx_sample_rate() unified with the half-duplicated fallback in _ensure_tx_transcoder: one helper, contract.tx_sample_rate_hzradio.audio_sample_rate → 48000.
  • Double-start tolerance broadened (MOR-541 review note): the handler previously string-matched only "Already transmitting" (LAN stream). The USB driver raises AudioDriverLifecycleError("TX stream already started.") — a poller-first double start on USB radios re-raised and closed the audio WS. _is_benign_tx_restart() now accepts "already transmitting" / "already started" case-insensitively.

Tests (tests/test_handlers_coverage.py, +7, TDD red→green)

  1. Neutral radio: audio_start txstart_tx() (no per-codec methods touched); stop → stop_tx().
  2. Legacy-only radio still traverses the old per-codec branches.
  3. _tx_codec() ordering: first-class wins over contract; contract-only; audio_codec fallback.
  4. Push byte-compat: browser PCM frame on a PCM radio — push_tx receives bytes identical to what push_audio_tx_pcm received on the legacy path.
  5. Regression: USB-style AudioDriverLifecycleError("TX stream already started.") on start → treated as already-transmitting, _tx_active=True, no raise (WS stays open).

4 of the new tests failed before the implementation (red), all pass after; existing handler tests pass unmodified.

Gate results (worktree)

  • uv run pytest tests/ -q --tb=short --ignore=tests/integration7352 passed, 9 skipped, 3 deselected, 11 xfailed, 1 xpassed, 0 failures
  • uv run ruff check src/ tests/ — clean; uv run ruff format --check — 551 files already formatted
  • uv run mypy src/ — 21 errors in 8 files, identical to baseline (zero in audio.py; zero new)
  • uv run lint-imports — 4 kept, 0 broken

🤖 Generated with Claude Code

Step 11/12 of the MOR-532 AudioTransport epic. The web audio handler's
TX path now prefers the codec/transport-neutral surface:

- audio_start/audio_stop tx use radio.start_tx()/stop_tx() when present,
  with a single explicit legacy per-codec fallback helper
  (_legacy_tx_lifecycle) for non-AudioTransport radios.
- All radio pushes in _handle_tx_audio route through radio.push_tx(...)
  (legacy push_audio_tx_pcm/opus fallback); browser-codec branching and
  transcode decisions still key off _tx_codec().
- _tx_codec() preference reordered: first-class audio_tx_codec ->
  contract tx_codec -> audio_codec.
- _tx_sample_rate() unified with the half-duplicated fallback chain in
  _ensure_tx_transcoder (contract -> radio.audio_sample_rate -> 48000).
- Double-start tolerance broadened (MOR-541 review note): besides the
  LAN stream's "Already transmitting", the USB driver's
  AudioDriverLifecycleError("TX stream already started.") is now treated
  as the benign poller-first reuse case instead of re-raising and
  closing the audio WebSocket.

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

morozsm commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Agent Review: PASS

Independent review of 1068c69f (MOR-544, AudioTransport 11/12 — AudioBroadcaster TX on the neutral surface). Scope confirmed: exactly 2 files, +188/−22.

1. Live-TX-path parity (per backend, vs main)

  • Icom LAN (IC-7610/705/9700, _audio_runtime_mixin): audio_tx_codec property returns contract.tx_codec when a contract exists (_audio_runtime_mixin.py:520), and IcomRadio.__init__ always builds the contract (runtime/radio.py:838-842) — so the _tx_codec() reorder picks the identical value as main's contract-first chain. Neutral start_tx()start_audio_tx_pcm() resolves sample_rate from contract.tx_sample_rate_hz (the same value main's web code passed explicitly via _tx_sample_rate()), channels from contract.tx_channels, frame_ms=20 — same effective kwargs, _pcm_tx_fmt set identically. Stop: legacy stop_audio_tx_pcm → stop_audio_tx_opus → stop_tx, so neutral stop_tx() is the same terminal call. Push: for the PCM-negotiated contract, legacy push_audio_tx_pcm forwarded the frame unchanged to push_tx; the neutral path pushes the same bytes — byte-identical wire payload on all three handler push sites (browser-PCM passthrough, opus→pcm transcode, opus passthrough). The transcode decision is unshifted because the codec value is unshifted.
  • FTX-1 (yaesu_cat): no audio_stream_contract attribute exists on the class, so main's chain fell to audio_codec = PCM_1CH_16BIT; new first pick audio_tx_codec = PCM_1CH_16BIT (yaesu_cat/radio.py:323) — identical. start_tx() opens the driver at audio_sample_rate/1ch/20ms = exactly what main's start_audio_tx_pcm(sample_rate=radio.audio_sample_rate) did. All push variants funnel to _push_pcm_tx on both paths.
  • Icom serial (X6200): audio_tx_codec hardcoded PCM_1CH_16BIT (_icom_serial_base.py:183); every shipped profile/global default negotiates contract.tx_codec = PCM_1CH_16BIT (rigs/*.toml, audio/route.py:222-228) — no decision shift. Neutral start_tx/push_tx/stop_tx reach the same _serial_audio_driver calls as the legacy shims.
  • Only divergence found (non-blocking): a custom profile with tx_codec = "OPUS_1CH" on a LAN radio + a browser sending PCM16: main raised RuntimeError("PCM TX not started") per frame (swallowed by the handler's catch-all → frames dropped); the neutral push_tx now pushes raw PCM onto the opus wire (garbage audio). That config was already non-functional on main and ships in no profile. Likewise the neutral push_tx skips legacy per-frame size validation, but push exceptions were already only logged (_handle_tx_audio catch-all), so no WS-lifecycle change. Worth a follow-up note in the epic's final step, not a blocker.

2. _is_benign_tx_restart (message matching)

  • The stated justification (avoiding a web→audio import) is architecturally incorrect: .importlinter layers place rigplane.web above rigplane.audio, so importing AudioDriverLifecycleError in the handler is allowed. However, message matching is still required regardless, because the LAN path raises a plain RuntimeError("Already transmitting") (lan_stream.py:599) with no distinct class — isinstance alone cannot cover both. Suggest fixing the docstring claim in a follow-up; the mechanism itself is sound.
  • Breadth audit: the only in-tree raisers of "already transmitting"/"already started" reachable from a TX start are the genuine idempotence guards — usb_driver.py:884 (if self.tx_running: raise AudioDriverLifecycleError("TX stream already started.")) and the LAN "Already transmitting". "RX stream already started." (usb_driver.py:809) is raised only from start_rx, unreachable in this try-block; backend-level "TX stream already running." wouldn't match but is shielded by the driver guard. The matcher is consulted only inside the audio_start direction=tx branch — it cannot swallow push or stop failures. Acceptable.

3. Double-start regression test

test_audio_handler_tolerates_usb_lifecycle_double_start raises the real AudioDriverLifecycleError("TX stream already started.") (verified: subclass of RuntimeError, exact prod message from usb_driver.py:884) from start_tx and asserts no exception propagates (which is what kept the WS reader alive) and _tx_active is True. The poller-first collision is real: radio_poller.py:1189 (MOR-543, on main) calls neutral start_tx() on PttOn before the browser's audio_start tx arrives. On main this closed the audio WS for USB radios; fixed here.

4. Legacy fallback completeness

_legacy_tx_lifecycle reproduces the exact old branches: PCM → start_audio_tx_pcm(sample_rate=self._tx_sample_rate()) / stop_audio_tx_pcm(); otherwise opus start/stop. All three push sites carry the correct named legacy fallback. _tx_sample_rate() now falls back to radio.audio_sample_rate before 48000 (main went straight to 48000) — this only affects contract-less legacy radios and aligns the started stream rate with the TX transcoder rate (which already used that chain), eliminating a latent start-rate/transcoder mismatch. Deliberate, defensible unification.

Evidence

  • Falsification: with origin/main's audio.py under the PR's tests → exactly the 4 substantive new tests fail; restored to PR head → 135/135 in tests/test_handlers_coverage.py.
  • uv run pytest tests/ -q --tb=short --ignore=tests/integration7352 passed, 9 skipped, 11 xfailed, 1 xpassed, 0 failures
  • uv run ruff check src/ tests/ → clean; ruff format --check → 551 files already formatted
  • uv run mypy src/ → 21 errors in 8 files (baseline; none in web/handlers/audio.py)
  • uv run lint-imports → 4 contracts kept, 0 broken
  • CI: grep-gate + quick green on head 1068c69f

Non-blocking follow-ups (no code change required for merge): (a) correct the _is_benign_tx_restart docstring's import-restriction rationale; (b) note the custom-opus-TX-profile + browser-PCM edge in the epic's closing step (MOR-545).

@morozsm morozsm merged commit 438930e into main Jun 9, 2026
4 checks passed
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