|
|
|
|
@@ -5,16 +5,16 @@ import VoiceCatCore
|
|
|
|
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
|
|
|
|
|
|
|
|
|
/// iOS audio routing layer — drives all iOS audio route selection via `AVAudioSession`
|
|
|
|
|
/// *before* the core (miniaudio) opens its device. miniaudio does NOT touch
|
|
|
|
|
/// `AVAudioSession` on iOS — but ONLY because the core deliberately opens its devices
|
|
|
|
|
/// through a `ma_context` configured with `sessionCategory = none` +
|
|
|
|
|
/// *before* the core (miniaudio) opens its device. This class is the sole owner of the
|
|
|
|
|
/// session: miniaudio does NOT touch `AVAudioSession` on iOS, because the core opens its
|
|
|
|
|
/// devices through a `ma_context` configured with `sessionCategory = none` +
|
|
|
|
|
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
|
|
|
|
|
/// `core/src/audio/audio_engine.cpp`). With miniaudio's default NULL-context path it WOULD
|
|
|
|
|
/// reset the category to `Record`/`Playback` with no options on every device open, wiping
|
|
|
|
|
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output (the long-standing
|
|
|
|
|
/// "stereo mic kills output" bug). With that disabled, this class is the sole owner of the
|
|
|
|
|
/// session. All iOS audio routing (input port selection, mic orientation/polar patterns,
|
|
|
|
|
/// HFP vs A2DP, measurement/raw mode, stereo capture) must be driven from here.
|
|
|
|
|
/// `core/src/audio/audio_engine.cpp`). Without that, miniaudio's default path resets the
|
|
|
|
|
/// category to `Record`/`Playback` with no options on every device open, wiping
|
|
|
|
|
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output — so that
|
|
|
|
|
/// config must stay in place. All iOS audio routing (input port selection, mic
|
|
|
|
|
/// orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) must be
|
|
|
|
|
/// driven from here.
|
|
|
|
|
///
|
|
|
|
|
/// The three user-facing choices:
|
|
|
|
|
/// 1. **Input port** — which physical input (built-in mic, Bluetooth HFP, headset,
|
|
|
|
|
@@ -32,16 +32,14 @@ private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAu
|
|
|
|
|
/// 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 (mirroring
|
|
|
|
|
/// TeamTalk5's `SoundDevicesModel.selectDataSource` + `UtilSound.setupSoundDevices`, which
|
|
|
|
|
/// achieves stereo mic + A2DP output simultaneously) is: `setPreferredDataSource(.stereo
|
|
|
|
|
/// source)` + `setPreferredPolarPattern(.stereo)` + `setPreferredInput(built-in mic)` +
|
|
|
|
|
/// `setInputDataSource(stereo source)`. The channel count itself is NOT requested via
|
|
|
|
|
/// `setPreferredInputNumberOfChannels(2)` — that session-level call is what collapses the
|
|
|
|
|
/// A2DP output route (the original "stereo kills output" bug). 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.
|
|
|
|
|
/// 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.
|
|
|
|
|
@@ -332,12 +330,9 @@ final class IOSAudioRouter: ObservableObject {
|
|
|
|
|
switch bluetoothMode {
|
|
|
|
|
case .btHfpVoice:
|
|
|
|
|
// Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the
|
|
|
|
|
// connected device. This matches TeamTalk5's default (UtilSound.swift:228):
|
|
|
|
|
// [.allowBluetoothHFP, .allowAirPlay, .allowBluetoothA2DP]
|
|
|
|
|
// Making HFP and A2DP mutually exclusive (HFP-only here) blocks A2DP headphones
|
|
|
|
|
// from receiving audio — the "Voice Chat kills Bluetooth output" regression.
|
|
|
|
|
// HFP is *preferred* (the system uses HFP when a two-way mic path is needed),
|
|
|
|
|
// but A2DP is still available for output-only scenarios.
|
|
|
|
|
// connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
|
|
|
|
|
// — that blocks A2DP headphones from receiving audio. HFP is preferred (the system
|
|
|
|
|
// uses it when a two-way mic path is needed); A2DP stays available for output-only.
|
|
|
|
|
options.insert(.allowBluetoothHFP)
|
|
|
|
|
options.insert(.allowBluetoothA2DP)
|
|
|
|
|
options.insert(.allowAirPlay)
|
|
|
|
|
@@ -345,8 +340,6 @@ final class IOSAudioRouter: ObservableObject {
|
|
|
|
|
// A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be
|
|
|
|
|
// an OUTPUT (A2DP), so the system routes the mic to the built-in mic — exactly
|
|
|
|
|
// what we want for "built-in mic + A2DP output", in either mono OR stereo.
|
|
|
|
|
// This matches TeamTalk5's A2DP mode (UtilSound.swift:232-233): remove HFP from
|
|
|
|
|
// the default set, leaving only A2DP.
|
|
|
|
|
options.insert(.allowBluetoothA2DP)
|
|
|
|
|
options.insert(.allowAirPlay)
|
|
|
|
|
case .builtInMicSpeaker:
|
|
|
|
|
@@ -354,12 +347,10 @@ final class IOSAudioRouter: ObservableObject {
|
|
|
|
|
options.insert(.defaultToSpeaker)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Set category + mode. Recipe validated against TeamTalk5 / Ferrite, which both do
|
|
|
|
|
// built-in stereo mic + A2DP Bluetooth output simultaneously:
|
|
|
|
|
// 2. Set category + mode, chosen per scenario:
|
|
|
|
|
// - Stereo capture: .default — .voiceChat (the AEC/VPIO path) forces MONO, so stereo
|
|
|
|
|
// is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
|
|
|
|
|
// keeps the A2DP output route alive. (Earlier .videoRecording + a session-level
|
|
|
|
|
// channel-count request collapsed A2DP output — the "stereo kills output" bug.)
|
|
|
|
|
// keeps the A2DP output route alive.
|
|
|
|
|
// - Mono raw/studio: .measurement — all system processing off.
|
|
|
|
|
// - Mono + A2DP output: .videoRecording — keeps A2DP output without VPIO (no AEC).
|
|
|
|
|
// - Mono standard (HFP or speaker): .voiceChat — hardware AEC/AGC/HPF.
|
|
|
|
|
@@ -384,13 +375,12 @@ final class IOSAudioRouter: ObservableObject {
|
|
|
|
|
// 3. Input & mic-capsule configuration.
|
|
|
|
|
if captureChannels == .stereo {
|
|
|
|
|
// Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
|
|
|
|
|
// route explicitly via setPreferredInput + setInputDataSource. The session-level
|
|
|
|
|
// channel-count call (setPreferredInputNumberOfChannels(2)) is what collapses the
|
|
|
|
|
// A2DP output route — NOT setPreferredInput (TeamTalk5 uses setPreferredInput and
|
|
|
|
|
// gets stereo + A2DP). With HFP disabled the system routes input to the built-in
|
|
|
|
|
// mic, but without the explicit preferred-input anchor the route can collapse
|
|
|
|
|
// during the mode switch (.voiceChat → .default) and the output dies. The channel
|
|
|
|
|
// count is requested by miniaudio at the audio-unit level (vc_set_capture_channels).
|
|
|
|
|
// route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
|
|
|
|
|
// the system routes input to the built-in mic, but without the explicit
|
|
|
|
|
// preferred-input anchor the route can collapse during the mode switch
|
|
|
|
|
// (.voiceChat → .default) and the output dies. The channel count is requested by
|
|
|
|
|
// miniaudio at the audio-unit level (vc_set_capture_channels), NOT via
|
|
|
|
|
// setPreferredInputNumberOfChannels(2) — that call collapses the A2DP output route.
|
|
|
|
|
configureStereoCapture(session: session)
|
|
|
|
|
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
|
|
|
|
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
|
|
|
|
@@ -411,18 +401,16 @@ final class IOSAudioRouter: ObservableObject {
|
|
|
|
|
updateWarnings()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Enable 2-channel capture on the built-in mic. Mirrors TeamTalk5's recipe
|
|
|
|
|
/// (`SoundDevicesModel.selectDataSource` + `UtilSound.setupSoundDevices`), which
|
|
|
|
|
/// achieves stereo mic + A2DP Bluetooth output simultaneously:
|
|
|
|
|
/// 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 (this is NOT
|
|
|
|
|
/// what collapses A2DP — the session-level `setPreferredInputNumberOfChannels(2)`
|
|
|
|
|
/// is. Without this anchor the route can collapse during the mode switch.)
|
|
|
|
|
/// 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 requested by miniaudio at the audio-unit level via
|
|
|
|
|
/// `vc_set_capture_channels(2)`. We do NOT call `setPreferredInputNumberOfChannels(2)`
|
|
|
|
|
/// — that session-level call is the one that collapses the A2DP output route.
|
|
|
|
|
/// `vc_set_capture_channels(2)`. We must NOT call `setPreferredInputNumberOfChannels(2)`
|
|
|
|
|
/// — that session-level call collapses the A2DP output route.
|
|
|
|
|
private func configureStereoCapture(session: AVAudioSession) {
|
|
|
|
|
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
|
|
|
|
else {
|
|
|
|
|
@@ -438,12 +426,10 @@ final class IOSAudioRouter: ObservableObject {
|
|
|
|
|
do {
|
|
|
|
|
try builtIn.setPreferredDataSource(stereoSource)
|
|
|
|
|
try stereoSource.setPreferredPolarPattern(.stereo)
|
|
|
|
|
// Anchor the input route explicitly. TeamTalk5 does this (SoundDevicesModel
|
|
|
|
|
// .selectDataSource:147); without it the route can collapse during the mode
|
|
|
|
|
// switch (.voiceChat → .default) and the A2DP output dies.
|
|
|
|
|
// Anchor the input route explicitly; without it the route can collapse during
|
|
|
|
|
// the mode switch (.voiceChat → .default) and the A2DP output dies.
|
|
|
|
|
try session.setPreferredInput(builtIn)
|
|
|
|
|
// Commit the data source at the session level (TeamTalk does this at
|
|
|
|
|
// SoundDevicesModel.selectDataSource:148). setPreferredDataSource alone only
|
|
|
|
|
// Commit the data source at the session level. setPreferredDataSource alone only
|
|
|
|
|
// sets the port-level preference; setInputDataSource makes it the active source.
|
|
|
|
|
try session.setInputDataSource(stereoSource)
|
|
|
|
|
logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")
|
|
|
|
|
|