diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index cd738dd..6a8461e 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -6,6 +6,24 @@ import AVFoundation @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var iosAudioLifecycleChannel: FlutterMethodChannel? private var iosPlatformChannel: FlutterMethodChannel? + private var iosAudioSessionChannel: FlutterMethodChannel? + + /// Tracks whether a voice channel is currently active. + /// + /// The AVAudioSession is intentionally not configured for VoIP at + /// app launch — that would interrupt other apps' audio (Spotify, + /// Apple Music, podcasts) the moment the user opens Chanora, even + /// when they're just reading chat. Production VoIP apps (Telegram + /// group calls, Signal, Discord, Element) only switch the session + /// to `.playAndRecord` + `.voiceChat` when the user actually joins + /// a voice channel. See `docs/architecture/sad.md` and the + /// `chanora/ios_audio_session` MethodChannel contract. + /// + /// This flag gates lifecycle handlers (interruption-ended, + /// media-services-reset) so we only rebuild the VoIP session if a + /// call is actually in progress. When false, those handlers leave + /// the session in the inactive `.ambient` baseline. + private var voiceSessionActive: Bool = false override func application( _ application: UIApplication, @@ -15,91 +33,27 @@ import AVFoundation ChanoraSileroSelfTest.run() } - // 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. + // AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes + // the launch-time .playAndRecord setup): // - // 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. + // At launch we set the category to .ambient and leave the + // session INACTIVE — matching the Telegram / Signal / Discord / + // Element / Jitsi pattern and Apple's guidance that "a VoIP + // app's audio session should not be active" while idle. + // Configuring .playAndRecord + .voiceChat at launch stops other + // apps' music (Spotify, Apple Music, podcasts) the moment the + // user opens Chanora, even when they are just reading text chat. + // + // VoIP configuration is engaged on voice-channel join via the + // `chanora/ios_audio_session` MethodChannel, driven from Dart + // by the BridgeEvent::AudioStarted / AudioStopped lifecycle. do { - let session = AVAudioSession.sharedInstance() - try session.setCategory( - .playAndRecord, - mode: .voiceChat, - // Mode rationale (May 2026, .voiceChat reinstated): - // - // We previously used .default mode after discovering that - // .voiceChat routed output through iOS's in-call audio - // channel, which made speaker output barely audible. That - // bug 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 re-binds - // on overrideOutputAudioPort. - // - // .voiceChat advantages over .default: - // * Tells iOS this is a VoIP session — other apps' audio - // is properly ducked/paused instead of competing. - // * Enables correct Bluetooth HFP negotiation without - // manual workarounds. - // * iOS treats the audio session as a "call" for priority - // purposes (won't be interrupted by notification sounds). - // * System-level CallKit integration (lock-screen controls). - // - // .defaultToSpeaker ensures output goes to the main speaker - // (not the earpiece) by default when no headphones are - // connected, compensating for the in-call channel's tendency - // to route to the earpiece. - // - // 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. - // .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] - ) - // Match VPIO / Opus frame cadence to reduce callback pressure. - try session.setPreferredIOBufferDuration(0.02) - try session.setPreferredSampleRate(48000.0) - logAudioSessionState(context: "setCategory") + try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) + logAudioSessionState(context: "launch-ambient") } catch { - NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)") + NSLog("chanora_flutter: AVAudioSession .ambient baseline 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 - ) - NotificationCenter.default.addObserver( self, selector: #selector(handleRouteChange(_:)), @@ -124,37 +78,60 @@ import AVFoundation 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() { + /// Activate the VoIP audio session. Called from Dart via the + /// `chanora/ios_audio_session` channel when a voice channel join + /// reaches the `BridgeEvent::AudioStarted` stage. Configures + /// .playAndRecord + .voiceChat with .mixWithOthers so other apps + /// (Spotify, podcasts) can keep playing alongside the voice + /// channel — matching the Telegram group-call UX. Idempotent: + /// repeated calls while already active are a no-op. + private func activateVoiceSession() { 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). - logAudioSessionState(context: "setActive") - let s = AVAudioSession.sharedInstance() - let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",") + let session = AVAudioSession.sharedInstance() + try session.setCategory( + .playAndRecord, + mode: .voiceChat, + options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers] + ) + try session.setPreferredIOBufferDuration(0.02) + try session.setPreferredSampleRate(48000.0) + try session.setActive(true, options: []) + voiceSessionActive = true + logAudioSessionState(context: "activateVoiceSession") + let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",") NSLog( - "chanora_flutter: AVAudioSession actual: " + - "sampleRate=\(s.sampleRate) " + - "ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " + - "inputs=[\(ins)] " + - "outputVolume=\(s.outputVolume)" + "chanora_flutter: voice session active: " + + "sampleRate=\(session.sampleRate) " + + "ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " + + "inputs=[\(ins)] outputVolume=\(session.outputVolume)" ) } catch { - NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)") + NSLog("chanora_flutter: activateVoiceSession failed: \(error)") } } + /// Deactivate the VoIP audio session and return to the idle + /// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped` + /// (intentional leave, disconnect, or connection lost). + /// `.notifyOthersOnDeactivation` lets other audio apps know they + /// can resume — best-effort: Apple Music / Podcasts resume + /// reliably, Spotify is not guaranteed. + private func deactivateVoiceSession() { + let session = AVAudioSession.sharedInstance() + do { + try session.setActive(false, options: [.notifyOthersOnDeactivation]) + } catch { + NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)") + } + do { + try session.setCategory(.ambient, mode: .default) + } catch { + NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)") + } + voiceSessionActive = false + logAudioSessionState(context: "deactivateVoiceSession") + } + /// Reads back the actual AVAudioSession state and logs it for /// SDD-098 compliance. Called after both setCategory and setActive /// to verify that the session accepted the requested configuration. @@ -219,25 +196,30 @@ import AVFoundation } @objc private func handleMediaServicesReset(_ notification: Notification) { - NSLog("chanora_flutter: media services reset") - do { - let session = AVAudioSession.sharedInstance() - try session.setCategory( - .playAndRecord, - mode: .voiceChat, - options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] - ) - try session.setPreferredIOBufferDuration(0.02) - try session.setPreferredSampleRate(48000.0) - try session.setActive(true, options: []) - logAudioSessionState(context: "mediaServicesWereReset") - } catch { - NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)") + NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)") + if voiceSessionActive { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory( + .playAndRecord, + mode: .voiceChat, + options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers] + ) + try session.setPreferredIOBufferDuration(0.02) + try session.setPreferredSampleRate(48000.0) + try session.setActive(true, options: []) + logAudioSessionState(context: "mediaServicesWereReset-voip") + } catch { + NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)") + } + } else { + do { + try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) + logAudioSessionState(context: "mediaServicesWereReset-ambient") + } catch { + NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)") + } } - // P1: After rebuilding the session, send the current route class to - // Rust so it can recompute the processing policy and reset the - // AudioUnit. The Rust side handles this via ios_handle_media_services_reset - // which calls ios_restart_voice_unit. let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute) NSLog("chanora_flutter: media services reset complete, route=\(routeClass)") iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass) @@ -269,6 +251,26 @@ import AVFoundation name: "chanora/ios_platform", binaryMessenger: engineBridge.applicationRegistrar.messenger() ) + iosAudioSessionChannel = FlutterMethodChannel( + name: "chanora/ios_audio_session", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil)) + return + } + switch call.method { + case "activateVoiceSession": + self.activateVoiceSession() + result(nil) + case "deactivateVoiceSession": + self.deactivateVoiceSession() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } iosPlatformChannel?.setMethodCallHandler { call, result in switch call.method { case "getMicrophonePermissionState": diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index e1dc44f..530edaa 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -22,6 +22,7 @@ import 'l10n/generated/app_localizations.dart'; import 'services/android_permissions_service.dart'; import 'services/app_bootstrap.dart'; import 'services/audio_lifecycle_service.dart'; +import 'services/ios_audio_session_controller.dart'; import 'services/channel_join_error_mapper.dart'; import 'services/connection_phase_state.dart'; import 'services/ios_permissions_service.dart'; @@ -765,8 +766,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _resetConnectionUiState(phase: ConnectionPhase.disconnected); }); case rust.BridgeEvent_AudioStarted(): + unawaited(iosAudioSessionController.activate()); _ensureStatsTimer(); case rust.BridgeEvent_AudioStopped(): + unawaited(iosAudioSessionController.deactivate()); _statsTimer?.cancel(); _statsTimer = null; case rust.BridgeEvent_PttCapability( diff --git a/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart b/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart new file mode 100644 index 0000000..f9f0a7d --- /dev/null +++ b/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart @@ -0,0 +1,59 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +const iosAudioSessionChannelName = 'chanora/ios_audio_session'; + +/// Controls the iOS AVAudioSession VoIP lifecycle from Dart. +/// +/// The Swift `AppDelegate` configures the session to `.ambient` at +/// launch and leaves it inactive. The session is only switched to +/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a +/// voice channel is actually active. This controller is the Dart +/// side of that contract — call [activate] when the Rust engine +/// emits `BridgeEvent::AudioStarted` and [deactivate] on +/// `BridgeEvent::AudioStopped`. +/// +/// On non-iOS platforms both methods are no-ops; the platforms +/// handle their own session lifecycle elsewhere (Android via +/// `AndroidAudioLifecycleController`, macOS via +/// `MacOSAudioLifecycle`, desktop has no exclusive session). +class IosAudioSessionController { + IosAudioSessionController({ + MethodChannel? channel, + bool? isIos, + }) : _channel = channel ?? const MethodChannel(iosAudioSessionChannelName), + _isIos = isIos ?? Platform.isIOS; + + final MethodChannel _channel; + final bool _isIos; + + Future activate() async { + if (!_isIos) return; + try { + await _channel.invokeMethod('activateVoiceSession'); + } on PlatformException { + // Swift side logs the failure via NSLog; surfacing the + // exception to the event handler would be noise. The Rust + // engine remains alive and will produce silence until the + // next route change or a manual leave/rejoin. + } + } + + Future deactivate() async { + if (!_isIos) return; + try { + await _channel.invokeMethod('deactivateVoiceSession'); + } on PlatformException { + // Same rationale as activate(): the Swift side logs. + // Worst case the session stays in .playAndRecord until the + // app is backgrounded — at which point iOS reclaims the + // session automatically. + } + } +} + +/// Default singleton used by [main.dart] event dispatch. Tests +/// should construct their own [IosAudioSessionController] with a +/// mocked channel rather than mutating this instance. +final iosAudioSessionController = IosAudioSessionController(); diff --git a/apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart b/apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart new file mode 100644 index 0000000..b660d3b --- /dev/null +++ b/apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/ios_audio_session_controller.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('IosAudioSessionController', () { + const channel = MethodChannel(iosAudioSessionChannelName); + final messenger = TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger; + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + test('channel name matches Swift contract', () { + expect(iosAudioSessionChannelName, 'chanora/ios_audio_session'); + }); + + test('activate invokes activateVoiceSession on iOS', () async { + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + + final controller = IosAudioSessionController( + channel: channel, + isIos: true, + ); + await controller.activate(); + + expect(calls.map((c) => c.method), ['activateVoiceSession']); + expect(calls.single.arguments, isNull); + }); + + test('deactivate invokes deactivateVoiceSession on iOS', () async { + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + + final controller = IosAudioSessionController( + channel: channel, + isIos: true, + ); + await controller.deactivate(); + + expect(calls.map((c) => c.method), ['deactivateVoiceSession']); + expect(calls.single.arguments, isNull); + }); + + test('activate is a no-op on non-iOS platforms', () async { + var invoked = false; + messenger.setMockMethodCallHandler(channel, (call) async { + invoked = true; + return null; + }); + + final controller = IosAudioSessionController( + channel: channel, + isIos: false, + ); + await controller.activate(); + await controller.deactivate(); + + expect(invoked, isFalse); + }); + + test('activate swallows PlatformException so engine keeps running', + () async { + messenger.setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'avaudiosession_failed'); + }); + + final controller = IosAudioSessionController( + channel: channel, + isIos: true, + ); + + await expectLater(controller.activate(), completes); + await expectLater(controller.deactivate(), completes); + }); + }); +} diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index 522776d..cbc0129 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -989,9 +989,26 @@ impl IosVoiceUnit { let output_gain_for_render = params.output_gain.clone(); let output_muted_for_render = params.output_muted.clone(); let audio_processing_stats_for_render = params.audio_processing_stats.clone(); + let wav_recorder_for_render = wav_recorder.clone(); // Level meter decimation: the render callback fires ~93 // times/sec, but the bridge consumer reads at ~30 Hz. let mut render_level_decimation: u32 = 0; + // Render-side reference recorder state. Accumulates downmixed + // mono samples until a 10 ms frame is full, then pushes to the + // recorder. `render_recorder_active` tracks whether the + // recorder is currently armed so we can reset the accumulator + // when it goes from off→on (avoids stitching pre-stop tail + // into the post-start head). + let mut render_recorder_active: bool = false; + let mut render_ref_len: usize = 0; + let mut render_ref_accum: [f32; crate::frame::FRAME_10MS_SAMPLES] = + [0.0; crate::frame::FRAME_10MS_SAMPLES]; + // Diagnostic counters sampled every 100 callbacks. + let mut cb_count: u64 = 0; + let mut last_num_frames: usize = 0; + let mut num_frames_changes: u64 = 0; + let mut callbacks_with_audio: u64 = 0; + let mut callbacks_with_silence: u64 = 0; unit.set_render_callback(move |args: render_callback::Args>| { let render_callback::Args { data, diff --git a/docs/implementation-status-2026-05-28.md b/docs/implementation-status-2026-05-28.md index 2b79811..5920bd3 100644 --- a/docs/implementation-status-2026-05-28.md +++ b/docs/implementation-status-2026-05-28.md @@ -64,7 +64,7 @@ |---|---| | DEC-012 legal/trademark/OSS review | Explicitly open — `v1.0.0-rc.1` is the candidate awaiting sign-off. Public release is blocked. | | Android Keystore-backed DEK | Deferred to v1.1. Android still uses file-fallback for the Data Encryption Key. | -| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift`; release readiness still requires device audio validation and candidate evidence attachment. | +| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` with call-scoped activation (idle `.ambient` baseline; VoIP `.playAndRecord` + `.voiceChat` + `.mixWithOthers` engaged only on `BridgeEvent::AudioStarted` via `chanora/ios_audio_session` MethodChannel). Release readiness still requires device audio validation and candidate evidence attachment. | | Candidate state-sync evidence attachment | Reducer tests exist and pass locally; release readiness still needs candidate CI/run IDs and runtime integration evidence attached before public release approval. | --- diff --git a/docs/srs.md b/docs/srs.md index 6b11506..0310df8 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -1317,7 +1317,7 @@ Therefore: - Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed. - Owner: Software Team -**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions. +**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions. The session shall be configured for VoIP (`.playAndRecord` + `.voiceChat` + `.mixWithOthers`) only while a voice channel is active, and shall return to a non-disruptive idle state (`.ambient`, inactive, with `.notifyOthersOnDeactivation`) at all other times so that other apps' audio (music, podcasts, navigation) is preserved when the user opens Chanora to read text chat. - Type: Platform / iOS - Stage: P0 / MVP diff --git a/docs/sysdes.md b/docs/sysdes.md index 81b0b9a..a7529f2 100644 --- a/docs/sysdes.md +++ b/docs/sysdes.md @@ -1353,7 +1353,7 @@ External Server -> Protocol Adapter -> Rust Core -> State Engine -> Bridge -> Fl | Protocol compatibility | SE-05, SE-11 | `tsclientlib` adapter, protocol probe, compatibility matrix | | Secure identity handling | SE-03, SE-16 | Platform secure storage, no plaintext private keys | | Diagnostic privacy | SE-17, SE-19 | Redaction before export, user-initiated export | -| Mobile foreground voice | SE-03, SE-14 | AVAudioSession, Android foreground service, lifecycle handling | +| Mobile foreground voice | SE-03, SE-14 | AVAudioSession (call-scoped VoIP activation with `.mixWithOthers`, idle `.ambient` baseline), Android foreground service, lifecycle handling | | Release trust | SE-18 | Signing, notarization, app-store release metadata review |