Recovery from the 99937c9 audio-device-change commit broadened the
route-change recovery set to "everything except categoryChange /
routeConfigurationChange", which added .override. But .override is
fired by our own applyA2dpSpeakerFallback() -> overrideOutputAudioPort,
which recoverAudio() calls on every recovery. On an A2DP preset
(Stereo Mic / Mono Mic), disconnecting AirPods ping-ponged:
oldDeviceUnavailable -> recoverAudio -> applyA2dpSpeakerFallback
-> overrideOutputAudioPort(.speaker) -> .override routeChange
-> recoverAudio -> applyConfiguration (setCategory resets override)
-> applyA2dpSpeakerFallback -> overrideOutputAudioPort -> .override -> ...
Each iteration also rebuilt the AVAudioEngine via reconfigure() ->
rebuild() -- the audible reinitialize loop + CPU spin. Voice Chat and
Built-in Mic + Speaker were unaffected (applyA2dpSpeakerFallback
early-returns for non-A2DP modes, so no overrideOutputAudioPort call).
Two-part fix (pure Swift iOS-app target; no C ABI / proto / docs changes):
1. AudioSessionManager.handleRouteChange: added .override to the skip
list alongside .categoryChange / .routeConfigurationChange. .override
is only ever fired by our own overrideOutputAudioPort call, so
treating it as a recovery reason is the loop by definition. The
AVAudioEngineConfigurationChange observer in IOSVoiceProcessingEngine
remains as the backstop if an override ever actually stops the engine.
2. IOSAudioRouter.applyA2dpSpeakerFallback: made idempotent via a
lastAppliedOutputOverride tracker that skips the redundant
overrideOutputAudioPort call when the desired state (.none for
external output present, .speaker otherwise) already matches. Reset
to nil at the top of applyConfiguration() (setCategory can reset the
override) and on a failed call. Defense-in-depth on top of fix 1.
Build: xcodebuild -project clients/apple/iOS/VoiceCatiOS.xcodeproj
-scheme VoiceCatiOS -destination 'generic/platform=iOS' build green
(Xcode 26.5 / iOS 18.0).
Apple client (macOS + iOS)
Built in M4 (see docs/roadmap.md). One shared Swift core
(VoiceCatCore package) wrapping the C ABI (core/include/voicecat.h),
with platform-specific UIs: AppKit for macOS (best VoiceOver accessibility), SwiftUI
for iOS. See docs/architecture.md §4 and
docs/tech-stack.md §2.
What's here now
VoiceCatCore Swift Package — ✓ complete (2026-06-18)
The shared Swift core that both the macOS AppKit app and the iOS SwiftUI app will consume.
Mirrors the Windows client's VoiceCat.Interop layer (clients/windows/)
using Swift-native C interop instead of P/Invoke.
clients/apple/
├── Package.swift # SPM: binary target (XCFramework) + VoiceCatCore library + tests
├── VoiceCatCore.xcframework/ # BUILT ARTIFACT — produced by scripts/build-xcframework.sh (gitignored)
├── scripts/
│ └── build-xcframework.sh # builds libvoicecat + vcpkg deps → fat .a → XCFramework + module map
├── Sources/VoiceCatCore/
│ ├── Enums.swift # Swift-idiomatic mirrors of the 9 voicecat.h C enums
│ ├── Config.swift # VoiceCatConfig (wraps vc_config)
│ ├── Event.swift # VoiceCatEvent — copies ev.text inside the callback (the #1 lifetime rule)
│ ├── Models.swift # Channel, User, Stream, Device, Permissions, Account, AudioConfig, …
│ ├── Marshaling.swift # C arrays → Swift arrays + immediate vc_free_* (callers never manage native lifetime)
│ ├── Callbacks.swift # @convention(c) on_event/on_level + Unmanaged.passUnretained context bridging
│ └── VoiceCatClient.swift # the public Swift surface — owns vc_client*, all 38 C functions, event delivery on @MainActor
└── Tests/VoiceCatCoreTests/
└── VoiceCatClientSmokeTests.swift # 6 XCTest smoke tests against a real voicecat-server (6/6 green)
Key patterns (carried over from the proven C# VoiceCat.Interop — see
docs/architecture.md §4 per-platform binding notes):
- C interop via module map:
import VoiceCatC— Swift sees all C enums/structs/functions directly. No manual struct/function redeclaration (unlike C# P/Invoke). The module map (module VoiceCatC { header "voicecat.h" }) is staged into the XCFramework headers bybuild-xcframework.sh. @convention(c)callbacks: plain C function pointers (not ARC-managed closures) +Unmanaged.passUnretained(self)as theusercontext — the Swift analog of C#'s[UnmanagedCallersOnly]+GCHandle.deinitcallsvc_client_destroy(joins all threads) before the object's memory is freed, so no callback can fire with a dangling pointer.- Config string lifetimes: the core stores raw pointers from
vc_config(doesn't copy). Native CString storage (strdup) is held for the client's entire lifetime, freed indeinitaftervc_client_destroy. - Event delivery: events buffered in a lock-protected array + coalesced
DispatchQueue.maindrain (one async block at a time) — the Swift analog of C#'sChannel<VoiceCatEvent>+ 30ms WinForms Timer pump.ev.textis copied toStringinside the callback before enqueueing (dangling-pointer rule). - Level meters: coalesced to latest-per-stream-id (intermediate values are visually
irrelevant, same as C#'s
ConcurrentDictionary<uint,float>). - Immediate
vc_free_*on list reads — callers never manage native list lifetime.
Tests — 6/6 green
swift test
# ✓ testVersionStringIsNonEmpty
# ✓ testResultStringRoundTrips
# ✓ testConnectTofuAuthListChannelsRoundTrips (connect → TOFU → confirm → guest auth → channels → permissions → guest ListAccounts rejected)
# ✓ testAdminChannelCrudAccountCrudRoundTrips (admin auth → channel create/edit/delete → account create/list/reset/delete)
# ✓ testScreenAudioStreamStartsAndStops (screen-audio stream start/stop through Swift interop)
# ✓ testPerStreamRecvControlsRoundTrip (two clients, per-stream gain/mute/NR round-trip)
Prerequisites for tests: cmake --preset dev && cmake --build --preset dev (builds
voicecat-server + voicecat-admin into build/dev/bin/).
What's NOT here yet (next steps)
- macOS AppKit app (
clients/apple/macOS/) — the M4 UI: connect dialog, saved-server list (Keychain for passwords), TOFU identity dialog, main window (NSOutlineView channel tree, NSTableView user list, NSTextView chat, activity log), voice controls, per-user tuning, full VoiceOver accessibility. Mirrors the WindowsVoiceCat.Appfeature set. - iOS SwiftUI app — AVAudioSession, mic permission, foreground voice.
vc_audio_suspend/vc_audio_resumeABI hooks — deferred until the iOS client milestone (keep ABI stable).- ReplayKit Broadcast Upload Extension for iOS
SCREEN_AUDIO(docs/voice.md§9). - macOS
SCREEN_AUDIOvia ScreenCaptureKit (currently stub returnsfalse). - iOS XCFramework slices —
apple-ios/apple-ios-simpresets are scaffolding; runscripts/build-xcframework.sh --allonce the iOS vcpkg triplets are validated.
Building the XCFramework
The XCFramework is a local build artifact (gitignored, like the Windows client's
build/windows-client/bin/voicecat.dll). Run the build script before swift build /
swift test:
# Prerequisites: VCPKG_ROOT set, Xcode installed
export VCPKG_ROOT=/path/to/vcpkg
# Build the macOS slice + fat static lib + XCFramework (validated)
scripts/build-xcframework.sh
# → clients/apple/VoiceCatCore.xcframework/ (macOS-arm64 slice)
# Build all 3 slices (macOS + iOS device + iOS sim) — iOS still scaffolding
scripts/build-xcframework.sh --all
Fat static library
The apple-dev CMake preset produces a 1.9 MB libvoicecat.a containing only voicecat's
own object files — vcpkg's static dependencies (protobuf, mbedtls, libsodium, opus, sqlite3,
spdlog, asio, abseil, …) are 107 separate .a files under vcpkg_installed/arm64-osx/lib/,
and the vendored RNNoise noise-suppression lib (third_party/rnnoise/, built as a CMake
target → build/<preset>/lib/librnnoise.a) is another. A Swift Package binary target can
only link ONE .a per XCFramework slice, so build-xcframework.sh merges them all — vcpkg
deps plus the locally-built vendored libs — into a single self-contained libvoicecat-fat.a
(~33 MB) using libtool -static. This is the Apple equivalent of how the Windows client
ships a single voicecat.dll with all deps statically linked (via MinGW's -static flags
in core/CMakeLists.txt). If you add another vendored (non-vcpkg)
static-lib target to the core, it's picked up automatically as long as it lands in
build/<preset>/lib/ and isn't named libvoicecat*.
Swift Package
swift build # builds VoiceCatCore library
swift test # runs 6 smoke tests against a real voicecat-server
The Package.swift declares:
- A binary target (
VoiceCatCoreXCF) pointing at the localVoiceCatCore.xcframework. - A library target (
VoiceCatCore) that depends on the binary target and provides the Swift wrapper. - A test target (
VoiceCatCoreTests) withlinkerSettings: [.linkedLibrary("c++")]— the fat static lib is C++20, so the final executable must link libc++ (the LLVM C++ standard library on macOS). vcpkg's static deps are already in the.a; macOS system frameworks (CoreAudio/CoreFoundation) are auto-discovered by the linker.
Ad-hoc distribution (iOS, pre-TestFlight)
To hand the iOS app to a handful of friends before TestFlight, use
scripts/dist-ios-adhoc.sh. It registers each device's
UDID, builds an ad-hoc-signed VoiceCatiOS.ipa, and generates the manifest.plist +
index.html for an over-the-air (itms-services://) web install. Ad-hoc builds only run on
devices whose UDID is registered before signing, and stock iOS won't install a bare .ipa
without a sideloading tool — so the web-install page is the friend-friendly path.
# One-time: create an App Store Connect API "Team Key" (.p8, Admin/App Manager access) at
# App Store Connect → Users and Access → Integrations → App Store Connect API
export ASC_KEY_ID=ABC123 ASC_ISSUER_ID=1111-... ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8
# Register a device + build + stage everything into dist/ios-adhoc/
scripts/dist-ios-adhoc.sh --udid <UDID> --name "Friend iPhone" \
--base-url https://example.com/voicecat
Then upload the three staged files (VoiceCatiOS.ipa, manifest.plist, index.html) to
that HTTPS folder and open index.html in Safari on a registered iPhone (iOS 18+).
Device UDID registration is automated via scripts/asc_api.py
(asc_api.py list shows the registered devices against the 100-iOS-devices/year cap).
Requires a paid Apple Developer Program membership. Run scripts/dist-ios-adhoc.sh --help
for all flags.