Per external review (helpful checklist from ChatGPT-style analysis pointing out we never verified that iOS actually accepted our preferred sample rate / channels / format): preferredSampleRate and preferredIOBufferDuration are HINTS, not guarantees. iOS may substitute its own values if the hardware can't satisfy our preference. If VPIO is running at 44.1 kHz Float32 stereo while our render callback writes 48 kHz Int16 mono into the buffer, the symptoms would match what user reports (broken playback, pitch shifted, severe distortion) and our previous diagnostics wouldn't catch it because they only sampled signal-level metrics. This commit adds two diagnostic emissions to verify: 1. AppDelegate.swift::activateAudioSession: after setActive succeeds, log the ACTUAL session state \u2014 category, mode, sampleRate, ioBufferDuration, current route (inputs + outputs), outputVolume. Lets us see whether iOS honoured our .default + .defaultToSpeaker setup and which physical route it picked at launch. 2. ios_voice_unit.rs::IosVoiceUnit::start: after unit.start() succeeds, log the actual OUTPUT and INPUT stream formats VPIO accepted (sample_rate, channels, sample_format, flags). If these differ from our requested 48 kHz Int16 mono, we have a format-substitution problem. Three possible outcomes from the next test: * Both diagnostics confirm 48 kHz Int16 mono on both buses and the session sampleRate=48000 -> format is correct; the playback breakage is somewhere else (e.g. AudioHandler jitter buffer behaviour, route binding, or hardware mixer). * Session sampleRate != 48000 -> we need to insert a sample rate converter or pin AVAudioSession's setPreferredSampleRate(48000) explicitly in Swift before setActive. * VPIO substituted Float32 for our Int16 request -> our render callback is writing i16 magnitudes into a Float32 buffer which would explain the distortion. Fix: write Float32 directly using data::Interleaved<f32> instead of i16. Build counter 70 -> 71. Pure diagnostic; no behavioural change.
187 lines
8.8 KiB
Swift
187 lines
8.8 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: .default,
|
|
// Mode rationale (re-revisited after the "low playback
|
|
// volume" investigation, May 2026):
|
|
//
|
|
// We've cycled through .voiceChat -> .default -> .voiceChat
|
|
// -> .default. Final answer is .default with
|
|
// .defaultToSpeaker, driven by these findings:
|
|
//
|
|
// The earlier "speaker selector silent" bug under
|
|
// .voiceChat was caused by cpal's RemoteIO unit binding
|
|
// to a stale physical transducer. 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 re-binds on
|
|
// overrideOutputAudioPort. So route switching is no
|
|
// longer a deciding factor.
|
|
//
|
|
// The "broken playback quality" bug under either
|
|
// .voiceChat or .default (with VPIO) was actually NOT
|
|
// a VPIO problem at all. iOS has TWO independent audio
|
|
// channels: the in-call channel (used by .voiceChat /
|
|
// .videoChat modes) and the media channel (used by
|
|
// .default). The in-call channel:
|
|
// * Routes through the phone-call audio path
|
|
// * Aggressively ducks non-voice content to the
|
|
// earpiece (Apple's "speakerphone vs ear" UX)
|
|
// * Volume controlled by separate in-call volume
|
|
// hardware, not the side buttons (when not in a
|
|
// phone call)
|
|
// The media channel:
|
|
// * Routes through the standard media playback path
|
|
// * No automatic ducking
|
|
// * Volume controlled by the side volume buttons
|
|
//
|
|
// Even with VPIO + .voiceChat producing a perfectly
|
|
// good signal, iOS's in-call channel routing made it
|
|
// play at "earpiece" loudness on the speaker too \u2014
|
|
// user-perceived as "broken and poor" because the
|
|
// signal is technically there but barely audible against
|
|
// the loud iPhone speaker's noise floor.
|
|
//
|
|
// Twilio's video-quickstart-ios and Daily.co's patched
|
|
// WebRTC both document the same workaround: use .default
|
|
// mode with .defaultToSpeaker option even when using
|
|
// VPIO for AEC. The VPIO unit itself still does its job
|
|
// (echo cancellation, noise suppression, AGC on the mic
|
|
// path) \u2014 only the playback routing changes.
|
|
//
|
|
// References:
|
|
// * https://github.com/twilio/video-quickstart-ios/issues/522
|
|
// * https://stackoverflow.com/questions/79834998 (Daily.co)
|
|
//
|
|
// Options:
|
|
// .defaultToSpeaker : route output to the main speaker
|
|
// (not the earpiece) by default
|
|
// when no headphones are connected.
|
|
// This is what makes the audio
|
|
// actually audible at normal
|
|
// loudness.
|
|
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
|
// Profile headsets as both input
|
|
// and output.
|
|
// .allowBluetoothA2DP : permit higher-quality A2DP
|
|
// output-only Bluetooth devices.
|
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
|
)
|
|
NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/default + defaultToSpeaker)")
|
|
} 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")
|
|
// Read back the ACTUAL session state. preferredSampleRate /
|
|
// preferredIOBufferDuration are hints; iOS may pick something
|
|
// else depending on hardware + currently-engaged effects.
|
|
// Without these we can't tell whether VPIO is running at
|
|
// 48 kHz mono (what our render callback assumes) or at e.g.
|
|
// 44.1 kHz (which would explain the user's broken playback
|
|
// \u2014 our render callback would be writing samples at the
|
|
// wrong rate, causing pitch + timing artifacts).
|
|
let s = AVAudioSession.sharedInstance()
|
|
let route = s.currentRoute
|
|
let outs = route.outputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
|
|
let ins = route.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
|
|
NSLog(
|
|
"chanora_flutter: AVAudioSession actual: " +
|
|
"category=\(s.category.rawValue) " +
|
|
"mode=\(s.mode.rawValue) " +
|
|
"sampleRate=\(s.sampleRate) " +
|
|
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
|
"outputs=[\(outs)] " +
|
|
"inputs=[\(ins)] " +
|
|
"outputVolume=\(s.outputVolume)"
|
|
)
|
|
} catch {
|
|
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
|
}
|
|
}
|