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
@@ -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();