Skip to content

fix(ios): cycle audio engine so TX actually renders to the radio - #921

Open
patrickrb wants to merge 2 commits into
devfrom
fix/ios-tx-render-silent
Open

patrickrb wants to merge 2 commits into
devfrom
fix/ios-tx-render-silent

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Problem

On iOS, TX never keyed the radio: the FT-891's Data-VOX stayed quiet and TUNE was silent at the radio too, even though RX decoded perfectly and the output route was the USB (DigiRig) interface at full volume. So this wasn't routing, levels, or the sequencer — the TX audio simply never left the audio engine.

Root cause

RX and TX share a single AVAudioEngine that AudioCaptureService starts for input capture only. The first code to ever touch mainMixerNode is the TX path's engine.connect(playerNode, to: mainMixerNode) in TxPlayerService.ensureConnected(). Forming that mixer → output link on an already-running engine leaves the new output chain un-pulled by the render thread — the player node keys without error, but no samples reach the hardware. play() only (re)started the engine when it was stopped, and the capture engine is always running, so the freshly-formed output connection was never kicked into rendering.

This is the silent-TX state left behind by PR #836 (which fixed the earlier crash on this same connect-after-config-change path).

Fix

  • ensureConnected() now reports whether it actually (re)connected the node.
  • After a fresh connect, play() cycles the engine (stop() → start()) once so the whole graph — player → mixer → output — comes up rendering.
  • Gated on an actual (re)connect via the pure, unit-tested TxConnectionState.mustCycleEngineAfterConnect(didConnect:), so steady-state transmit slots don't stutter the RX capture tap every cycle.
  • Also covers the post-config-change reconnect (DigiRig replug), which returns through the same path.

Testing

  • TxConnectionStateTests extended with the cycle-after-connect decision (cycle on fresh connect, not on steady state). Full FT8AFTests suite passes on the iPhone 16 simulator (Xcode 26.6):

    Executed 8 tests, with 0 failures
    ** TEST SUCCEEDED **
    
  • ⚠️ On-device verification still needed: the render-thread behavior that causes the silent TX can't be reproduced in unit tests or on Simulator. Please confirm on the phone + DigiRig + FT-891 that TUNE now keys the rig and a QSO transmits (shows up on PSKReporter) before merging.

🤖 Generated with Claude Code

iOS TX kept silent on air — the rig's Data-VOX never keyed and TUNE was
silent at the radio too, even though RX decoded fine and the output route
was the USB interface at full volume. The waveform (full-scale GFSK) and
session routing were both correct; the audio simply never left the engine.

Root cause: RX and TX share one AVAudioEngine that AudioCaptureService starts
for *input* capture only. The first thing to touch mainMixerNode is the TX
path's `engine.connect(playerNode, to: mainMixerNode)` in ensureConnected().
Forming that mixer -> output link on an already-running engine leaves the new
output chain un-pulled by the render thread: the node keys without error but
no samples reach the hardware. play() only (re)started the engine when it was
stopped, and the capture engine is always running, so the freshly-formed
output connection was never brought up rendering. (PR #836 fixed the earlier
crash on this same path; this is the silent-TX state it left behind.)

Fix: ensureConnected() now reports whether it actually (re)connected, and
play() cycles the engine (stop -> start) once after a fresh connect so the
whole graph — player -> mixer -> output — comes up rendering. Gated on an
actual (re)connect via the pure, unit-tested
TxConnectionState.mustCycleEngineAfterConnect(didConnect:), so steady-state
transmit slots don't stutter the RX capture tap. This also covers the
post-config-change reconnect (DigiRig replug), which returns through the same
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 41.50%. Comparing base (0677e40) to head (b0f95d5).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##                dev     #921      +/-   ##
============================================
- Coverage     41.51%   41.50%   -0.01%     
  Complexity      252      252              
============================================
  Files           280      280              
  Lines         33993    33992       -1     
  Branches       4034     4063      +29     
============================================
- Hits          14112    14110       -2     
+ Misses        19592    19591       -1     
- Partials        289      291       +2     
Flag Coverage Δ
ios 97.71% <ø> (ø)
native 9.85% <ø> (-0.09%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

handleConfigChange, the engine-restart failure path and the keying
exception path each repeated the same invalidate-and-drop-converter
sequence; route all three through invalidateConnectionLocked().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickrb

Copy link
Copy Markdown
Owner Author

Review round 1

Claude code-review (high), used as the fallback because Copilot isn't available on this repo. I reviewed this by reading the code against AVAudioEngine's semantics. Swift can't be built on the reviewing machine, and none of this has been checked on a device.

# Finding Outcome
1 If engine.start() throws after the new engine.stop(), the shared capture engine stays stopped. Only the TX connection is invalidated, so RX goes deaf while AudioCaptureService still reports running. Left for the maintainer. A real fix needs a way to tell AudioCaptureService that RX is down (or a shared owner for the engine's lifecycle), and no such hook exists today. The next play() restarts the engine through its existing !isRunning check. Failing to start right after a successful start should be rare, but it's a new failure mode this PR introduces.
2 The stop/start happens inside play(), after LiveEngine.beginTxPlayback has computed clipMs and the late-start skip. So the first TX after a (re)connect keys later than the timing math assumes, by the restart time (likely tens to hundreds of ms), and the restart blocks the main actor. Left for the maintainer. It costs one slot per (re)connect, and adding delay doesn't clip the leading Costas array. Avoiding it means connecting and cycling before TX time (row 5), which is a design choice that needs a device to verify.
3 Restarting the engine with output newly enabled might itself post AVAudioEngineConfigurationChange. That would invalidate the connection again, so every slot would reconnect and cycle. Not changed. Check on the device. This is speculative: once the output chain exists, later restarts shouldn't change the I/O config, and the check can't be done without hardware. On the device, watch that only the first TX after start or a replug glitches RX, not every TX.
4 When the engine was stopped, play() starts it, connects, then stops and starts it again: two hardware cycles. Not changed. That only happens when the engine is stopped at TX time. Normally AudioCaptureService's config-change handler has already restarted it. Connecting before the first start would need the Simulator's 0 Hz mixer-format fallback reworked, and that can't be verified here.
5 Root-cause alternative: touch engine.mainMixerNode (or connect the player) in AudioCaptureService.installTapAndStart() before engine.start(), so the output chain is built from the start and the shared engine never has to be cycled during a session. Left for the maintainer. This would likely fix rows 1, 2 and 4 together, but it changes when the output chain exists, and deferring that was the earlier Simulator 0 Hz workaround. Pick an approach after testing on the phone + DigiRig.
6 TxPlayerService now drives the lifecycle of an engine that AudioCaptureService owns, outside that service's lock and state. Not changed. It's the same concern as row 1, and play() already restarted a stopped engine before this PR. Both calls run on the main actor, as does AudioCaptureService.handleConfigChange's restart, so they don't interleave.
7 mustCycleEngineAfterConnect(didConnect:) just returns its input, so its tests only check the identity. Not changed. It follows the repo's extract-and-test rule (CLAUDE.md), and the AVAudioEngine calls around it can't be unit-tested. It mainly documents the decision.
8 The invalidate-connection-and-drop-converter block is now copied three times (handleConfigChange, restart failure, keying exception). Fixed. All three now go through a private invalidateConnectionLocked().

Fix commit: b0f95d5 (a pure refactor with no behavior change). Nothing ran locally, since there's no Swift toolchain on this Windows machine. Verification is the PR's swift test + simulator build CI job. Note that this job runs the FT8AFKit package tests and a simulator build. It does not run the app's FT8AFTests target, so TxConnectionStateTests ran only in the author's local Xcode run. Not verified on a device: please confirm on the phone + DigiRig + FT-891 that TUNE keys the rig, a QSO transmits, and RX keeps decoding through the first TX.

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