User report at +66: capture (mic -> remote) is clean, but local playback (remote -> speaker) is 'broken and poor', particularly for human voice. Musicbot audio (loud, near-continuous) plays correctly; human voice (peaks ~-6 dB, average ~-40 dB, classic 20 dB peak-to-average ratio) sounds gated out so most inter- phoneme content is unintelligible. Diagnostic at +66 (render callback peak sampling every 100 callbacks during a 60-second talker session) showed peak_stereo values in the 0.005-0.01 range with occasional 0.13-0.49 spikes \u2014 i.e. the signal is REAL and reaching the device, but VPIO's output-side voice processing chain is gating the average-level content. Root cause: AVAudioSession mode .default + VPIO is a mismatched pairing. Under .default mode the VPIO unit's internal AGC/NS thresholds are tuned wrong for telephony-style speech and treat quiet inter-phoneme content as noise to gate out. Fix: switch back to mode .voiceChat which is Apple's documented pair for VoiceProcessingIO. WebRTC's reference iOS audio device manager (chromium googlesource voice_processing_audio_unit.mm) also uses this pair. VPIO under .voiceChat tunes its processing chain for speech and passes quiet content through cleanly. The original 'speaker/receiver toggle is silent under .voiceChat' bug was caused by cpal's RemoteIO unit binding to a stale physical transducer at construction time, not by .voiceChat itself. After migrating to VPIO at commits 1-4 (af686cathroughe7c3ffa) the route binding is correct under either mode because VPIO natively re-binds on overrideOutputAudioPort \u2014 it IS the canonical voice unit. So .default lost its only benefit and we revert to the Apple-documented pairing. Category options unchanged: .allowBluetoothHFP + .allowBluetoothA2DP \u2014 BT headsets still permitted in both directions regardless of mode. Build counter 66 -> 67.
134 lines
6.2 KiB
Swift
134 lines
6.2 KiB
Swift
import UIKit
|
|
import Flutter
|
|
import AVFoundation
|
|
|
|
@main
|
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
|
override func application(
|
|
_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
) -> Bool {
|
|
// Configure the iOS AVAudioSession **category + mode** at
|
|
// app-launch time, but DEFER setActive(true) until the scene
|
|
// is foregrounded. Calling setActive in didFinishLaunching is
|
|
// racy on iOS 17+ devices: if the user launches the app from a
|
|
// cold state, the UIApplication isn't yet `.active` and
|
|
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
|
|
// (561017449) — the iOS audio policy server refuses to grant
|
|
// the audio session because the app is not yet considered the
|
|
// foreground priority owner. Symptom in production builds:
|
|
// 'AVAudioSession setup failed: Error 561017449 "Session
|
|
// activation failed"' in NSLog, after which the audio engine
|
|
// is unusable until the user backgrounds + foregrounds the
|
|
// app.
|
|
//
|
|
// The category itself can be set whenever; only the active
|
|
// state needs to be deferred. We listen for
|
|
// didBecomeActiveNotification and activate then. Most
|
|
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
|
// this same shape.
|
|
do {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(
|
|
.playAndRecord,
|
|
mode: .voiceChat,
|
|
// Mode rationale (revisited after the iOS VPIO migration):
|
|
//
|
|
// We previously tried .default mode to fix the
|
|
// "speaker/receiver toggle is silent" bug \u2014 that bug was
|
|
// ultimately caused by cpal's iOS RemoteIO unit binding to
|
|
// a stale physical transducer, not by .voiceChat itself.
|
|
// After migrating to coreaudio-rs + kAudioUnitSubType_VoiceProcessingIO
|
|
// (see crates/chanora_audio/src/ios_voice_unit.rs) the
|
|
// route binding is correct under either mode because VPIO
|
|
// is the canonical voice unit and natively re-binds on
|
|
// overrideOutputAudioPort. So .default lost its only
|
|
// benefit.
|
|
//
|
|
// Under .default mode, VPIO's output-side voice processing
|
|
// chain (echo subtraction, noise gating) interprets low-
|
|
// amplitude playback signal as "no farend audio" and
|
|
// aggressively gates inter-phoneme content. Symptom in
|
|
// testing: musicbot (loud, continuous signal) plays fine,
|
|
// human voice (peaks ~-6 dB, average ~-40 dB, classic
|
|
// 20 dB peak-to-average ratio) sounds broken and
|
|
// unintelligible \u2014 the quiet samples between phonemes
|
|
// get gated out, destroying intelligibility.
|
|
//
|
|
// Apple's documentation explicitly pairs VPIO with
|
|
// AVAudioSessionModeVoiceChat. WebRTC's reference iOS
|
|
// ADM implementation uses the same pair. Under
|
|
// .voiceChat mode VPIO's internal AGC/AEC/NS thresholds
|
|
// are tuned for telephony-style speech and pass quiet
|
|
// inter-phoneme content through cleanly.
|
|
//
|
|
// Category options unchanged \u2014 .allowBluetoothHFP +
|
|
// .allowBluetoothA2DP permit BT headsets in both
|
|
// directions regardless of mode.
|
|
options: [.allowBluetoothHFP, .allowBluetoothA2DP]
|
|
)
|
|
NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)")
|
|
} catch {
|
|
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
|
}
|
|
|
|
// Activate the session once the app is actually foreground. The
|
|
// notification fires immediately after the cold-launch settles,
|
|
// and again on every resume-from-background — both safe
|
|
// moments to call setActive(true). Repeated activation while
|
|
// already-active is a no-op per the docs.
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(activateAudioSession),
|
|
name: UIApplication.didBecomeActiveNotification,
|
|
object: nil
|
|
)
|
|
|
|
// Request microphone access on first launch rather than waiting
|
|
// for the user's first voice-channel join. The latter is
|
|
// surprising: the user has only tapped "connect to server" and
|
|
// suddenly iOS pops the permission prompt because joining a
|
|
// text channel happens to trigger audio engine startup. Asking
|
|
// up-front matches user expectations for a voice-chat client.
|
|
//
|
|
// The request is asynchronous and non-blocking. If the user
|
|
// denies, voice_join will surface a clearer error later when
|
|
// the audio engine fails to open the input device. The
|
|
// permission state is cached by iOS so subsequent launches
|
|
// skip the prompt.
|
|
//
|
|
// Deferred ~1 s so iOS finishes initialising the keyboard /
|
|
// text-input subsystem before the permission alert appears.
|
|
// Firing the alert too early steals focus from the not-yet-
|
|
// ready text-input layer, with the symptom that the first tap
|
|
// on a TextField does nothing (the second tap works because
|
|
// by then iOS has caught up). DispatchQueue.main.asyncAfter
|
|
// keeps everything on the main thread; the permission API
|
|
// itself must be called there too.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
NSLog("chanora_flutter: microphone permission granted=\(granted)")
|
|
}
|
|
}
|
|
|
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
}
|
|
|
|
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
|
/// every resume-from-background). Activates the AVAudioSession.
|
|
/// Repeated activation is a no-op when the session is already
|
|
/// active so this is safe to call on every foreground.
|
|
@objc private func activateAudioSession() {
|
|
do {
|
|
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
|
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
|
} catch {
|
|
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
|
}
|
|
}
|