From 8a16db2899d279d8c751f9a1734bda179ca49634 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 10 Oct 2026 07:57:32 -0500 Subject: [PATCH 1/2] fix(ios): cycle the audio engine so TX actually renders to the radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/FT8AF/FT8AF/Engine/TxPlayerService.swift | 61 ++++++++++++++++--- .../FT8AFTests/TxConnectionStateTests.swift | 16 +++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift index a9faf3455..8ad3c8bf3 100644 --- a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift +++ b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift @@ -30,6 +30,23 @@ struct TxConnectionState { /// connection in the graph, so the next `play()` must reconnect and rebuild /// the format converter against the (possibly new) mixer output format. mutating func invalidateConnection() { isConnected = false } + + /// Whether `play()` must cycle (stop→start) the shared engine after + /// `ensureConnected()` ran. + /// + /// The capture engine is started for RX *input* only, so the first thing to + /// touch `mainMixerNode` is the TX path's `engine.connect(player, to: + /// mainMixer)`. 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, so TX is + /// audible nowhere and the rig's Data-VOX never triggers (TUNE is silent at + /// the radio too, since it shares this path). Cycling the engine brings the + /// whole graph — including `player → mixer → output` — up rendering. + /// + /// Only needed when a (re)connect actually happened; the steady-state TX + /// (already connected, nothing torn down) must NOT cycle, or every slot + /// would stutter the capture tap. + static func mustCycleEngineAfterConnect(didConnect: Bool) -> Bool { didConnect } } /// Plays a mono 12 kHz FT8 waveform through the device speaker using an @@ -101,16 +118,17 @@ final class TxPlayerService: @unchecked Sendable { /// Attach (once) and connect the player node, creating the format /// converter. Reconnects and rebuilds the converter when a prior config - /// change invalidated the connection. Returns the converter, or `nil` if the - /// hardware output format is invalid (e.g. 0 Hz on Simulator at startup) — - /// the caller should skip playback. Returning the converter directly (rather + /// change invalidated the connection. Returns the converter (or `nil` if the + /// hardware output format is invalid — e.g. 0 Hz on Simulator at startup, in + /// which case the caller should skip playback) and whether this call + /// actually (re)connected the node. Returning the converter directly (rather /// than letting `play()` re-read `outputConverter`) keeps the read under the /// lock, so a concurrent `handleConfigChange` can't nil it between the check /// and the use. - private func ensureConnected() -> AVAudioConverter? { + private func ensureConnected() -> (converter: AVAudioConverter?, didConnect: Bool) { lock.lock(); defer { lock.unlock() } - if !conn.needsConnect { return outputConverter } - guard let engine else { return nil } + if !conn.needsConnect { return (outputConverter, false) } + guard let engine else { return (nil, false) } // Use the mixer's output format — on real devices this is the // hardware rate (e.g. 48 kHz stereo); on Simulator it becomes valid @@ -118,7 +136,7 @@ final class TxPlayerService: @unchecked Sendable { // different rate (e.g. the DigiRig's), so it must be re-read here. let mixerFormat = engine.mainMixerNode.outputFormat(forBus: 0) guard mixerFormat.sampleRate > 0, mixerFormat.channelCount > 0 else { - return nil + return (nil, false) } if conn.needsAttach { @@ -127,9 +145,9 @@ final class TxPlayerService: @unchecked Sendable { } engine.connect(playerNode, to: engine.mainMixerNode, format: mixerFormat) outputConverter = AVAudioConverter(from: ft8Format, to: mixerFormat) - guard outputConverter != nil else { return nil } + guard outputConverter != nil else { return (nil, false) } conn.markConnected() - return outputConverter + return (outputConverter, true) } /// Schedule and play `samples` (mono Float32 at 12 kHz). Interrupts any @@ -159,11 +177,34 @@ final class TxPlayerService: @unchecked Sendable { // Re-establish the node connection if a config change invalidated it. // Skipping this is what aborts the app: play() on a disconnected node // raises an uncaught NSException in AVFAudio. - guard let converter = ensureConnected() else { + let (maybeConverter, didConnect) = ensureConnected() + guard let converter = maybeConverter else { completion?() return false } + // A fresh (re)connect formed the player → mixer → output chain on the + // already-running capture engine, where the render thread won't pull it + // — so TX keys silently (no audio out any route; the rig's Data-VOX + // never triggers). Cycle the engine once to bring the new output chain + // up rendering. Only on an actual (re)connect: steady-state TX skips + // this, so the capture tap isn't stuttered every slot. + if TxConnectionState.mustCycleEngineAfterConnect(didConnect: didConnect), + let engine { + engine.stop() + do { + try engine.start() + } catch { + // Couldn't bring the engine back; the connection is now stale. + lock.lock() + conn.invalidateConnection() + outputConverter = nil + lock.unlock() + completion?() + return false + } + } + // Wrap input samples in a PCM buffer at 12 kHz. let frameCount = AVAudioFrameCount(samples.count) guard let inputBuffer = AVAudioPCMBuffer(pcmFormat: ft8Format, frameCapacity: frameCount) else { diff --git a/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift b/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift index be37b637a..183479eab 100644 --- a/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift +++ b/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift @@ -74,4 +74,20 @@ final class TxConnectionStateTests: XCTestCase { XCTAssertFalse(s.needsConnect) } } + + // MARK: - Engine-cycle-after-connect decision + + /// The silent-TX regression: a (re)connect forms the player → mixer → + /// output chain on the already-running capture engine, which the render + /// thread won't pull, so `play()` must cycle the engine to make it render. + func testMustCycleEngineAfterAFreshConnect() { + XCTAssertTrue(TxConnectionState.mustCycleEngineAfterConnect(didConnect: true)) + } + + /// Steady-state TX (already connected, nothing reconnected) must NOT cycle + /// the engine — otherwise every transmit slot would stutter the RX capture + /// tap for no reason. + func testDoesNotCycleEngineWhenAlreadyConnected() { + XCTAssertFalse(TxConnectionState.mustCycleEngineAfterConnect(didConnect: false)) + } } From b0f95d5ff9a77d6c898b0e895fcede386e5e85f9 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 22 Oct 2026 10:13:30 -0500 Subject: [PATCH 2/2] Factor the TX connection reset into one helper 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 --- ios/FT8AF/FT8AF/Engine/TxPlayerService.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift index 8ad3c8bf3..12506b3c2 100644 --- a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift +++ b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift @@ -111,9 +111,15 @@ final class TxPlayerService: @unchecked Sendable { /// keying a disconnected node (which aborts the app). @objc private func handleConfigChange() { lock.lock() + invalidateConnectionLocked() + lock.unlock() + } + + /// Mark the node's connection stale and drop the converter so the next + /// `play()` reconnects. Caller must hold `lock`. + private func invalidateConnectionLocked() { conn.invalidateConnection() outputConverter = nil - lock.unlock() } /// Attach (once) and connect the player node, creating the format @@ -197,8 +203,7 @@ final class TxPlayerService: @unchecked Sendable { } catch { // Couldn't bring the engine back; the connection is now stale. lock.lock() - conn.invalidateConnection() - outputConverter = nil + invalidateConnectionLocked() lock.unlock() completion?() return false @@ -264,8 +269,7 @@ final class TxPlayerService: @unchecked Sendable { if raised != nil { lock.lock() _isPlaying = false - conn.invalidateConnection() - outputConverter = nil + invalidateConnectionLocked() lock.unlock() completion?() return false