Skip to content

fix: audio pipeline survives speaker toggle during LXST calls#3

Merged
torlando-tech merged 2 commits into
mainfrom
fix/audio-speaker-toggle
Mar 24, 2026
Merged

fix: audio pipeline survives speaker toggle during LXST calls#3
torlando-tech merged 2 commits into
mainfrom
fix/audio-speaker-toggle

Conversation

@torlando-tech

@torlando-tech torlando-tech commented Mar 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • Toggling speaker during a voice call crashed with Failed to create tap due to format mismatch because the config change handler queried a stale codec format (24kHz) instead of the real hardware format (48kHz)
  • Fix: use AVAudioSession.sharedInstance().sampleRate for the tap format instead of the stale inputNode.outputFormat query
  • Also applies the same fix to handleAudioSessionActivated() (same latent bug)
  • Adds ColumbaAppTests target with AudioRingBuffer and AudioManager config change tests (17 tests, all passing)
  • Fixes duplicate reticulum-swift / reticulum-swift-lib package identity conflict that blocked simulator builds

Root cause

After AVAudioEngineConfigurationChange, the engine is stopped by iOS. Querying inputNode.outputFormat(forBus: 0) returns the negotiated codec rate (24kHz from setPreferredSampleRate), but the hardware has reverted to native 48kHz. The mismatch crashes installTap.

Test plan

  • Start voice call, verify audio works in earpiece
  • Toggle speaker ON — audio continues both directions
  • Toggle speaker OFF — audio continues both directions
  • Rapid toggle multiple times — no crash
  • Full call lifecycle (answer → talk → toggle → hang up)
  • Run AudioRingBufferTests (7 tests) and AudioManagerConfigChangeTests (10 tests) — all pass on simulator

🤖 Generated with Claude Code

torlando-tech and others added 2 commits March 24, 2026 14:23
Toggling speaker during a voice call fires AVAudioEngineConfigurationChange.
The handler queried inputNode.outputFormat(forBus: 0) after the engine was
stopped, which returned the stale codec format (1ch 24kHz) instead of the
real hardware format (1ch 48kHz). Installing a tap with that mismatched
format crashed with "Failed to create tap due to format mismatch".

Fix: use AVAudioSession.sharedInstance().sampleRate (the actual hardware
rate) to create the tap format, instead of trusting the stale inputNode
query. The drain loop resamples from the real HW rate to the codec rate.

Also adds ColumbaAppTests target with AudioRingBuffer and AudioManager
configuration change tests.

Verified on-device: speaker toggle in both directions, rapid toggling,
and full call lifecycle all work without crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… builds

The local package reference pointed to ../reticulum-swift-lib but the
GitHub repo was renamed to reticulum-swift. SPM derived identity
"reticulum-swift-lib" from the directory name, conflicting with the
"reticulum-swift" identity from LXMF/LXST transitive dependencies.

Fix: point the local reference to ../reticulum-swift (which has the
matching Package.swift on main). Also add shared xcscheme with test
target configured so xcodebuild test works.

All 17 tests pass on simulator (10 AudioManager + 7 AudioRingBuffer).

Note: device builds may need Xcode signing re-auth after the package
re-resolution invalidated DerivedData cached provisioning profiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit 4dbc791 into main Mar 24, 2026
@torlando-tech torlando-tech deleted the fix/audio-speaker-toggle branch March 24, 2026 19:06
torlando-tech pushed a commit that referenced this pull request May 26, 2026
Closes the loop on the lxst-wiring batch. With this commit a call placed
from sim1 to sim2 fully establishes through the canonical Mark Qvist
Python RNS+LXST embedded in BeeWare — the full Reticulum LXST signal
exchange (AVAILABLE → LINKIDENTIFY → RINGING → CONNECTING → ESTABLISHED
+ PREFERRED_PROFILE) crosses the bridge.

Bridge wiring completed in this commit:

  - Compat Link: identifyHook (parallels sendBytesHook), wired by
    AppServices to PythonRNSBackend.linkIdentify(linkId:) so the
    caller's `link.identify(identity:)` call propagates through Python
    and the remote sees the proper IDENTIFY signal.
  - Compat Link.identifyCallbacks: was weakly held, became strong.
    Telephone.handleIncomingLink constructs a
    `TelephoneIdentifyHandler(telephone: self)` inline with no caller-
    held reference; under weak storage it deallocated immediately and
    the inbound call silently stalled at AVAILABLE, never receiving
    the identify-callback invocation when Python's link_identified
    event arrived. Strong storage fixes it.
  - app/rns_bridge.py: announce_telephony() function so the LXST
    telephony destination announces alongside the LXMF delivery one;
    sendAllAnnounces() invokes both. Without this, sim2 couldn't see
    sim1's voice destination in its path table.
  - URL test triggers: lxma://test-call?to=HEX[&profile=...] places
    an outbound call through CallManager.initiateCall; lxma://test-answer
    auto-accepts the ringing call. Mirrors the existing lxma://test-* set.
  - CallManager auto-answer escape hatch keyed off env var
    COLUMBA_AUTO_ANSWER=1. Reason: simctl openurl only delivers URLs
    to the frontmost simulator window; sim2 stays backgrounded during
    the sim1 → sim2 test so we can't drive the answer via URL. The
    env var lets the smoke test reliably push sim2 past RINGING.

Smoke test (iPhone 17 Pro / 17 Pro Max sims, fresh boots):

  sim1 → sim2:  [TEST-CALL] to=e6abaf41... profile=qualityMedium
  sim1:         [TEL_BRIDGE] opened outbound link 1 → e6abaf41
  sim2:         [PY] link 1 state=established inbound=true
  sim2:         [CALL] handleIncomingLink
  sim2:         [TEL] sendData #1: 4B          (AVAILABLE)
  sim1:         [TEL] handlePacket first4=81009103 (AVAILABLE)
  sim1:         (link.identify → linkIdentifyHook → Python linkIdentify)
  sim2:         [PY] link 1 identified=cb54d9a4
  sim2:         [CALL] Ringing from: cb54d9a4...
  sim2:         [TEL] sendData #2: 4B          (RINGING)
  sim2:         [TEL] PREFERRED_PROFILE: Medium Quality
  sim1:         [CALL] Ringing from: 4e4f5bcb...
  sim2:         [CALL] COLUMBA_AUTO_ANSWER=1 — auto-answering
  sim2:         [TEL] answer(): sending CONNECTING, profile=Medium Quality
  sim2:         [TEL] sendData #3: 4B          (CONNECTING)
  sim2:         [TEL] answer(): pipeline started, codec=opus
  sim2:         [TEL] answer(): sent ESTABLISHED
  sim2:         [CALL] establishedCallback fired
  sim2:         [AUDIO] startAudio() creating AudioManager
  sim1:         [TEL] handlePacket first4=81009105 (CONNECTING)
  sim1:         [TEL] handlePacket first4=81009106 (ESTABLISHED)
  sim1:         [AUDIO] startAudio() creating AudioManager

Audio frame round-trip (what we'd ideally see next, e.g. opus
"frame #1: hdr=0x01 audioBytes=N") doesn't fire on the simulator:
both sides reach [AUDIO] startAudio() but then log "deferring engine
start until didActivateAudioSession" because CallKit on the simulator
doesn't drive its audio-session-activation delegate the way a physical
device does (the sim has no real audio hardware to bind to). The
signaling path is end-to-end across the Python bridge; verifying
actual audio bytes traversing requires installing on Torlando's USB-
attached iPhone, which is a separate workstream from this 5-commit
batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <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