Files
voice-cat/clients/apple
Talon dcb7e6eeca fix(ios): fix stereo mic + A2DP output silence
Three coordinated fixes for the bug where enabling stereo mic capture
caused all audio output (A2DP, speaker, wired) to go silent:

1. audio_engine.cpp — open playback before capture
   On iOS, starting the stereo capture AudioUnit can trigger an audio
   route reconfiguration that drops A2DP before the playback device has
   a chance to claim the route. Opening and starting the playback device
   first commits the output route (A2DP), so iOS is less likely to drop
   it when stereo capture activates afterward.

2. client.cpp — decouple set_capture_channels from engine restart
   Previously vc_set_capture_channels() stopped and restarted the engine
   immediately, which opened capture first (old ordering) and raced
   against the settling AVAudioSession route. Now it only stores the
   channel count; the caller (Swift via vc_audio_restart) controls when
   the engine restarts, after the route has settled.

3. IOSAudioRouter.swift — call audioRestart() after channel config
   selectCaptureChannels() and applyPreset() now call audioRestart()
   after applyConfiguration() + setCaptureChannels(). This is the
   vc_audio_restart() path that was added to the ABI in fdcc84f but
   never wired up in the Swift layer. The restart sees the stored
   channel count and reopens devices in the correct order (playback
   first, capture second).

The doStartMicStream path is unaffected: setCaptureChannels is called
before the server acknowledges the stream (engine not yet running), so
ensure_audio_running() picks up capture_channels=2 directly when the
stream is confirmed and opens with the right count from the start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 17:39:23 +02:00
..

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 by build-xcframework.sh.
  • @convention(c) callbacks: plain C function pointers (not ARC-managed closures) + Unmanaged.passUnretained(self) as the user context — the Swift analog of C#'s [UnmanagedCallersOnly] + GCHandle. deinit calls vc_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 in deinit after vc_client_destroy.
  • Event delivery: events buffered in a lock-protected array + coalesced DispatchQueue.main drain (one async block at a time) — the Swift analog of C#'s Channel<VoiceCatEvent> + 30ms WinForms Timer pump. ev.text is copied to String inside 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 Windows VoiceCat.App feature set.
  • iOS SwiftUI app — AVAudioSession, mic permission, foreground voice.
  • vc_audio_suspend/vc_audio_resume ABI hooks — deferred until the iOS client milestone (keep ABI stable).
  • ReplayKit Broadcast Upload Extension for iOS SCREEN_AUDIO (docs/voice.md §9).
  • macOS SCREEN_AUDIO via ScreenCaptureKit (currently stub returns false).
  • iOS XCFramework slicesapple-ios / apple-ios-sim presets are scaffolding; run scripts/build-xcframework.sh --all once 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/. A Swift Package binary target can only link ONE .a per XCFramework slice, so build-xcframework.sh merges them all into a single self-contained libvoicecat-fat.a (~30 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).

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 local VoiceCatCore.xcframework.
  • A library target (VoiceCatCore) that depends on the binary target and provides the Swift wrapper.
  • A test target (VoiceCatCoreTests) with linkerSettings: [.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.