docs: condense implementation comments
This commit is contained in:
@@ -4,48 +4,8 @@ import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
||||
|
||||
/// iOS audio routing layer — the sole owner of `AVAudioSession` on iOS. On iOS the core never
|
||||
/// opens a hardware (miniaudio) device: a single `AVAudioEngine` (`IOSAudioEngine`) drives both
|
||||
/// capture and playback and the core runs fully external (see docs/voice.md §8). This class just
|
||||
/// configures the *route* — category / mode / options, preferred input, data source, polar
|
||||
/// pattern, stereo capsule — and `IOSAudioEngine` binds to whatever route is established. After
|
||||
/// any change here the engine is rebuilt via `IOSAudioEngine.reconfigure()` (a deterministic
|
||||
/// Swift-only stop → reconfigure → start); there is no second (miniaudio) audio path to hand off
|
||||
/// to, so a change cannot leave one direction dropped.
|
||||
///
|
||||
/// (The core's iOS `ma_context` is still configured with `sessionCategory = none` +
|
||||
/// `noAudioSessionActivate/Deactivate` in `AudioEngine::make_context_config` so that, should the
|
||||
/// core ever open a device, miniaudio would not reset the category — but on iOS it does not.)
|
||||
///
|
||||
/// The three user-facing choices:
|
||||
/// 1. **Input port** — which physical input (built-in mic, Bluetooth HFP, headset,
|
||||
/// USB, AirPlay). For the built-in mic, a sub-selection of **data source**
|
||||
/// (orientation: front/back/top/bottom) and **polar pattern**
|
||||
/// (omni/cardioid/subcardioid/bidirectional).
|
||||
/// 2. **Bluetooth mode** — how Bluetooth headsets are handled:
|
||||
/// - "BT HFP voice" (`.allowBluetoothHFP` + `.allowBluetoothA2DP`): both profiles
|
||||
/// allowed, iOS picks HFP for two-way mic or A2DP for output-only. Mono, AEC on.
|
||||
/// - "Built-in Mic + BT A2DP stereo" (`.allowBluetoothA2DP` only): stereo output,
|
||||
/// built-in mic, no HFP processing.
|
||||
/// - "Built-in Mic + Speaker" (neither): no Bluetooth at all.
|
||||
/// 3. **Mic processing mode** — Standard (`.voiceChat`: AEC/AGC/HPF on) or
|
||||
/// Raw/Studio (`.measurement`: all processing off). Raw mode is allowed always
|
||||
/// but shows a warning when the output route is the speaker (echo risk, no AEC).
|
||||
///
|
||||
/// Additionally, **stereo capture** (2-channel built-in mic) is enabled by switching the
|
||||
/// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
|
||||
/// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
|
||||
/// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
|
||||
/// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` — that
|
||||
/// session-level call collapses the A2DP output route. Instead the core is told to open the
|
||||
/// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
|
||||
/// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
|
||||
/// the HFP→A2DP and mono→stereo reconfigurations.
|
||||
///
|
||||
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
|
||||
/// for `.voiceChat` apps — surfaced as a hint, not a programmatic toggle.
|
||||
///
|
||||
/// All choices are persisted in `UserDefaults` and re-applied on route changes.
|
||||
/// Owns `AVAudioSession` routing for the iOS external-audio path.
|
||||
/// Route configuration and ordering constraints are documented in `docs/voice.md`.
|
||||
@MainActor
|
||||
final class IOSAudioRouter: ObservableObject {
|
||||
|
||||
@@ -156,17 +116,10 @@ final class IOSAudioRouter: ObservableObject {
|
||||
private let kVoiceProcessing = "cat.voice.audio.voiceProcessing"
|
||||
private let kAgc = "cat.voice.audio.agc"
|
||||
|
||||
/// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change
|
||||
/// notifications synchronously on the same thread. Without this guard,
|
||||
/// handleRouteChange → applyConfiguration → setCategory → route-change notification
|
||||
/// → handleRouteChange → applyConfiguration → ... creates an infinite loop that
|
||||
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
|
||||
/// AVAudioSession setters can synchronously emit route-change notifications.
|
||||
private var isApplyingConfiguration = false
|
||||
|
||||
/// Last `overrideOutputAudioPort` value we successfully applied (`.none` or `.speaker`).
|
||||
/// See `applyA2dpSpeakerFallback`'s doc comment for why this cache exists.
|
||||
/// `nil` = "unknown / assume not applied" — reset at the top of `applyConfiguration()`
|
||||
/// because `setCategory` can reset the override out from under us, and on first run.
|
||||
/// Prevents redundant overrides; `setCategory` invalidates the cached value.
|
||||
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
|
||||
|
||||
private init() {}
|
||||
@@ -399,16 +352,8 @@ final class IOSAudioRouter: ObservableObject {
|
||||
updateWarnings()
|
||||
}
|
||||
|
||||
/// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
|
||||
/// A2DP Bluetooth output simultaneously:
|
||||
/// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port
|
||||
/// 2. `setPreferredPolarPattern(.stereo)` on that data source
|
||||
/// 3. `setPreferredInput(builtIn)` — anchor the input route explicitly. Without this
|
||||
/// anchor the route can collapse during the mode switch (.voiceChat → .default).
|
||||
/// 4. `setInputDataSource(stereoSource)` — commit the data source at the session level
|
||||
/// The channel count itself is carried by the engine's mic tap (which captures 2 channels)
|
||||
/// plus `vc_set_capture_channels(2)` so the core encodes stereo. We must NOT call
|
||||
/// `setPreferredInputNumberOfChannels(2)` — that session-level call collapses the A2DP route.
|
||||
/// Anchors the built-in stereo data source without using
|
||||
/// `setPreferredInputNumberOfChannels`, which disrupts A2DP routing.
|
||||
private func configureStereoCapture(session: AVAudioSession) {
|
||||
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||
else {
|
||||
@@ -530,11 +475,7 @@ final class IOSAudioRouter: ObservableObject {
|
||||
|
||||
// MARK: - Selection setters (called from SettingsView pickers)
|
||||
|
||||
/// Shared tail for every setting change: persist, re-apply the AVAudioSession config, refresh
|
||||
/// the route lists, re-evaluate the A2DP speaker fallback, and rebind the live engine to the
|
||||
/// new route. `IOSAudioEngine.reconfigure()` is a no-op when not connected, so this is safe to
|
||||
/// call from Settings whether or not a session is in progress. There is no longer a second
|
||||
/// (miniaudio) audio path to hand off to, so one engine rebuild is the whole story.
|
||||
/// Persists the selection and rebuilds the engine against the resulting route.
|
||||
private func applyAndReconfigure() {
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
@@ -658,23 +599,8 @@ final class IOSAudioRouter: ObservableObject {
|
||||
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
|
||||
}
|
||||
|
||||
/// Route fallback for the A2DP-output presets (Stereo Mic / Studio / BT Headphones + Mono
|
||||
/// Mic, all `.builtInMicBtA2dp`). These presets deliberately omit `.defaultToSpeaker` (it
|
||||
/// breaks A2DP routing) and skip the `forceSpeaker` override, so when NO external output
|
||||
/// (Bluetooth A2DP / wired / AirPlay) is connected `.playAndRecord` pins output to the quiet
|
||||
/// built-in receiver (earpiece). This routes to the loud built-in speaker instead via a
|
||||
/// post-activation `overrideOutputAudioPort(.speaker)` — the documented "A2DP if connected,
|
||||
/// else speaker" behavior. When an external output IS present we clear the override so A2DP /
|
||||
/// headphones / AirPlay are honored. No-op outside `.builtInMicBtA2dp` mode (other modes pick
|
||||
/// their route via category options). Must be called AFTER the session is active.
|
||||
///
|
||||
/// Idempotent: skips the `overrideOutputAudioPort` call when the desired override already
|
||||
/// matches the last one we successfully applied. Each call fires a `.override` route-change
|
||||
/// notification, and `AudioSessionManager.recoverAudio()` invokes this on every recovery —
|
||||
/// so on an AirPods disconnect, without this guard, override + recoverAudio ping-pong and
|
||||
/// each iteration also rebuilds the AVAudioEngine (the audible reinitialize loop). The cache
|
||||
/// is reset to `nil` at the top of `applyConfiguration()` (setCategory can reset the
|
||||
/// override) and on a failed call (so the next attempt re-derives from the live session).
|
||||
/// Uses the speaker only when an A2DP-capable preset has no external output.
|
||||
/// The cached override avoids recursively generated route-change notifications.
|
||||
func applyA2dpSpeakerFallback() {
|
||||
guard bluetoothMode == .builtInMicBtA2dp else { return }
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
|
||||
Reference in New Issue
Block a user