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:
@@ -6,6 +6,24 @@ import AVFoundation
|
|||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||||
private var iosPlatformChannel: 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(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -15,91 +33,27 @@ import AVFoundation
|
|||||||
ChanoraSileroSelfTest.run()
|
ChanoraSileroSelfTest.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure the iOS AVAudioSession **category + mode** at
|
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
|
||||||
// app-launch time, but DEFER setActive(true) until the scene
|
// the launch-time .playAndRecord setup):
|
||||||
// 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
|
// At launch we set the category to .ambient and leave the
|
||||||
// state needs to be deferred. We listen for
|
// session INACTIVE — matching the Telegram / Signal / Discord /
|
||||||
// didBecomeActiveNotification and activate then. Most
|
// Element / Jitsi pattern and Apple's guidance that "a VoIP
|
||||||
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
// app's audio session should not be active" while idle.
|
||||||
// this same shape.
|
// 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 {
|
do {
|
||||||
let session = AVAudioSession.sharedInstance()
|
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||||
try session.setCategory(
|
logAudioSessionState(context: "launch-ambient")
|
||||||
.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")
|
|
||||||
} catch {
|
} 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(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleRouteChange(_:)),
|
selector: #selector(handleRouteChange(_:)),
|
||||||
@@ -124,37 +78,60 @@ import AVFoundation
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
/// Activate the VoIP audio session. Called from Dart via the
|
||||||
/// every resume-from-background). Activates the AVAudioSession.
|
/// `chanora/ios_audio_session` channel when a voice channel join
|
||||||
/// Repeated activation is a no-op when the session is already
|
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
|
||||||
/// active so this is safe to call on every foreground.
|
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||||
@objc private func activateAudioSession() {
|
/// (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 {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
let session = AVAudioSession.sharedInstance()
|
||||||
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
try session.setCategory(
|
||||||
// Read back the ACTUAL session state. preferredSampleRate /
|
.playAndRecord,
|
||||||
// preferredIOBufferDuration are hints; iOS may pick something
|
mode: .voiceChat,
|
||||||
// else depending on hardware + currently-engaged effects.
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||||
// Without these we can't tell whether VPIO is running at
|
)
|
||||||
// 48 kHz mono (what our render callback assumes) or at e.g.
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
// 44.1 kHz (which would explain the user's broken playback
|
try session.setPreferredSampleRate(48000.0)
|
||||||
// \u2014 our render callback would be writing samples at the
|
try session.setActive(true, options: [])
|
||||||
// wrong rate, causing pitch + timing artifacts).
|
voiceSessionActive = true
|
||||||
logAudioSessionState(context: "setActive")
|
logAudioSessionState(context: "activateVoiceSession")
|
||||||
let s = AVAudioSession.sharedInstance()
|
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||||
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
|
||||||
NSLog(
|
NSLog(
|
||||||
"chanora_flutter: AVAudioSession actual: " +
|
"chanora_flutter: voice session active: " +
|
||||||
"sampleRate=\(s.sampleRate) " +
|
"sampleRate=\(session.sampleRate) " +
|
||||||
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
|
||||||
"inputs=[\(ins)] " +
|
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
|
||||||
"outputVolume=\(s.outputVolume)"
|
|
||||||
)
|
)
|
||||||
} catch {
|
} 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
|
/// Reads back the actual AVAudioSession state and logs it for
|
||||||
/// SDD-098 compliance. Called after both setCategory and setActive
|
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||||
/// to verify that the session accepted the requested configuration.
|
/// to verify that the session accepted the requested configuration.
|
||||||
@@ -219,25 +196,30 @@ import AVFoundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||||
NSLog("chanora_flutter: media services reset")
|
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
|
||||||
do {
|
if voiceSessionActive {
|
||||||
let session = AVAudioSession.sharedInstance()
|
do {
|
||||||
try session.setCategory(
|
let session = AVAudioSession.sharedInstance()
|
||||||
.playAndRecord,
|
try session.setCategory(
|
||||||
mode: .voiceChat,
|
.playAndRecord,
|
||||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
mode: .voiceChat,
|
||||||
)
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||||
try session.setPreferredIOBufferDuration(0.02)
|
)
|
||||||
try session.setPreferredSampleRate(48000.0)
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
try session.setActive(true, options: [])
|
try session.setPreferredSampleRate(48000.0)
|
||||||
logAudioSessionState(context: "mediaServicesWereReset")
|
try session.setActive(true, options: [])
|
||||||
} catch {
|
logAudioSessionState(context: "mediaServicesWereReset-voip")
|
||||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
} 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)
|
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||||
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||||
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||||
@@ -269,6 +251,26 @@ import AVFoundation
|
|||||||
name: "chanora/ios_platform",
|
name: "chanora/ios_platform",
|
||||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
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
|
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "getMicrophonePermissionState":
|
case "getMicrophonePermissionState":
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'l10n/generated/app_localizations.dart';
|
|||||||
import 'services/android_permissions_service.dart';
|
import 'services/android_permissions_service.dart';
|
||||||
import 'services/app_bootstrap.dart';
|
import 'services/app_bootstrap.dart';
|
||||||
import 'services/audio_lifecycle_service.dart';
|
import 'services/audio_lifecycle_service.dart';
|
||||||
|
import 'services/ios_audio_session_controller.dart';
|
||||||
import 'services/channel_join_error_mapper.dart';
|
import 'services/channel_join_error_mapper.dart';
|
||||||
import 'services/connection_phase_state.dart';
|
import 'services/connection_phase_state.dart';
|
||||||
import 'services/ios_permissions_service.dart';
|
import 'services/ios_permissions_service.dart';
|
||||||
@@ -765,8 +766,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
||||||
});
|
});
|
||||||
case rust.BridgeEvent_AudioStarted():
|
case rust.BridgeEvent_AudioStarted():
|
||||||
|
unawaited(iosAudioSessionController.activate());
|
||||||
_ensureStatsTimer();
|
_ensureStatsTimer();
|
||||||
case rust.BridgeEvent_AudioStopped():
|
case rust.BridgeEvent_AudioStopped():
|
||||||
|
unawaited(iosAudioSessionController.deactivate());
|
||||||
_statsTimer?.cancel();
|
_statsTimer?.cancel();
|
||||||
_statsTimer = null;
|
_statsTimer = null;
|
||||||
case rust.BridgeEvent_PttCapability(
|
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_gain_for_render = params.output_gain.clone();
|
||||||
let output_muted_for_render = params.output_muted.clone();
|
let output_muted_for_render = params.output_muted.clone();
|
||||||
let audio_processing_stats_for_render = params.audio_processing_stats.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
|
// Level meter decimation: the render callback fires ~93
|
||||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
// times/sec, but the bridge consumer reads at ~30 Hz.
|
||||||
let mut render_level_decimation: u32 = 0;
|
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>>| {
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
let render_callback::Args {
|
let render_callback::Args {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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
@@ -1317,7 +1317,7 @@ Therefore:
|
|||||||
- Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed.
|
- Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed.
|
||||||
- Owner: Software Team
|
- 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
|
- Type: Platform / iOS
|
||||||
- Stage: P0 / MVP
|
- Stage: P0 / MVP
|
||||||
|
|||||||
+1
-1
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Release trust | SE-18 | Signing, notarization, app-store release metadata review |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user