fix(voice,ios): scope AVAudioSession VoiceChat to call lifetime (#33)

Adopt a call-scoped VoIP audio session lifecycle so other apps' audio is
not stopped while Chanora is idle and the in-call session does not get
clobbered by media-server resets unrelated to voice.

AppDelegate.swift
- Set .ambient + .mixWithOthers as the idle baseline so the app does not
  hold a VoiceChat session when no call is active.
- Switch to .playAndRecord + .voiceChat + .mixWithOthers + .duckOthers
  on demand via the new chanora/ios_audio_session MethodChannel, and
  revert to .ambient on deactivate.
- Gate the media-services-reset rebuild on voiceSessionActive so a
  stray reset during idle no longer reactivates VoiceChat.

ios_audio_session_controller.dart (new)
- Thin Dart wrapper around chanora/ios_audio_session with activate/
  deactivate; no-op on non-iOS; swallows PlatformException to keep
  audio start/stop resilient to platform-side races.

main.dart
- Activate the iOS audio session on BridgeEvent_AudioStarted, deactivate
  on BridgeEvent_AudioStopped, fire-and-forget via unawaited().

ios_voice_unit.rs
- Add the 10 local bindings required by the render-callback closure
  preamble (wav_recorder_for_render, render_recorder_active,
  render_ref_len, render_ref_accum, cb_count, last_num_frames,
  num_frames_changes, callbacks_with_audio, callbacks_with_silence)
  so the iOS target compiles cleanly with the new lifecycle wiring.

Tests
- 5 unit tests in test/services/ios_audio_session_controller_test.dart
  cover activate/deactivate on iOS, no-op on non-iOS, and graceful
  PlatformException handling.

Docs
- SRS SRS-110 expanded to cover the call-scoped lifecycle invariant.
- SysDes mobile-voice row updated to reflect the MethodChannel and
  .ambient idle baseline.
- implementation-status-2026-05-28 voiceChat row flipped to done.

Verification
- flutter analyze: No issues found (2.8s)
- flutter test: 195 passed / 2 skipped / 0 failed
- cargo build -p chanora_audio --target aarch64-apple-ios: clean
- cargo build -p chanora_audio (macOS host): clean

Device QA matrix (Spotify-keeps-playing-while-idle, mix-during-call,
revert-on-call-end, media-services-reset-during-idle) remains pending
on physical hardware.
This commit is contained in:
Edison Jwa
2026-06-08 06:02:12 +09:00
committed by GitHub
parent 68a7892e19
commit a0ff17b935
8 changed files with 294 additions and 125 deletions
+113 -111
View File
@@ -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")
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]
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
)
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
try session.setActive(true, options: [])
logAudioSessionState(context: "mediaServicesWereReset")
logAudioSessionState(context: "mediaServicesWereReset-voip")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild 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.
} 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)")
}
}
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":
+3
View File
@@ -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(
@@ -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<void> activate() async {
if (!_isIos) return;
try {
await _channel.invokeMethod<void>('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<void> deactivate() async {
if (!_isIos) return;
try {
await _channel.invokeMethod<void>('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();
@@ -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 = <MethodCall>[];
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 = <MethodCall>[];
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);
});
});
}
@@ -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<data::Interleaved<i16>>| {
let render_callback::Args {
data,
+1 -1
View File
@@ -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. |
---
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 |