diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index 6b464ac..4bdcca1 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -85,6 +85,20 @@ import AVFoundation object: nil ) + // Diagnostic: subscribe to every AVAudioSession route change so + // we can correlate user picker actions with what iOS actually + // does to the route. The reason printed here ("override", + // "newDeviceAvailable", "categoryChange", etc.) tells us + // whether our Dart-side override was honoured or silently + // reverted by another component (e.g. cpal's RemoteIO unit + // reacting to its own configuration change). + NotificationCenter.default.addObserver( + self, + selector: #selector(handleRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, + 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 @@ -123,11 +137,69 @@ import AVFoundation do { try AVAudioSession.sharedInstance().setActive(true, options: []) NSLog("chanora_flutter: AVAudioSession activated on foreground") + logSessionState(tag: "didBecomeActive") } catch { NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)") } } + /// Log the full AVAudioSession state with a diagnostic tag. Used + /// after every state transition (setActive, route change) so we + /// can correlate user-perceived audio bugs with what iOS thinks + /// the session looks like. Output is parseable by grep + /// `chanora.session\[`. + private func logSessionState(tag: String) { + 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: ",") + let preferredInput = s.preferredInput?.portName ?? "" + NSLog(""" + chanora.session[\(tag)] \ + cat=\(s.category.rawValue) \ + mode=\(s.mode.rawValue) \ + sr=\(s.sampleRate) \ + ioBuf=\(String(format: "%.4f", s.ioBufferDuration)) \ + out=[\(outs)] in=[\(ins)] \ + preferredInput=\(preferredInput) + """) + } + + /// Called by `routeChangeNotification`. iOS posts this whenever + /// the route is reconfigured for any reason — user toggles + /// Bluetooth, plugs in headphones, our `overrideOutputAudioPort` + /// call, OR (the suspected bug here) when cpal's RemoteIO + /// AudioUnit reacts to its own format change and resets the + /// route. The `reason` field tells us which case it is: + /// + /// * `.override` : our Dart-side override took effect. + /// * `.routeConfigurationChange` : something else (cpal?) + /// triggered an internal reconfig. + /// * `.newDeviceAvailable` / `.oldDeviceUnavailable` : + /// user hardware change. + /// * `.categoryChange` : someone (us or another app) set a + /// new category. + @objc private func handleRouteChange(_ note: Notification) { + guard let reasonValue = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + NSLog("chanora.routeChange[unknown-reason]") + return + } + let reasonName: String + switch reason { + case .unknown: reasonName = "unknown" + case .newDeviceAvailable: reasonName = "newDeviceAvailable" + case .oldDeviceUnavailable: reasonName = "oldDeviceUnavailable" + case .categoryChange: reasonName = "categoryChange" + case .override: reasonName = "override" + case .wakeFromSleep: reasonName = "wakeFromSleep" + case .noSuitableRouteForCategory: reasonName = "noSuitableRouteForCategory" + case .routeConfigurationChange: reasonName = "routeConfigurationChange" + @unknown default: reasonName = "default(\(reasonValue))" + } + logSessionState(tag: "routeChange.\(reasonName)") + } + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) } diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 67cd5f0..1a59bc1 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -766,65 +766,78 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { } } + /// Diagnostic helper: log the current route's first input and + /// output ports with a tag so we can correlate user actions with + /// what iOS thinks the route is. Called immediately before an + /// override, immediately after, and again 250 ms later to detect + /// silent revert. + Future _logRoute(String tag) async { + try { + final route = await AVAudioSession().currentRoute; + final outs = route.outputs + .map((o) => '${o.portType.name}/${o.portName}') + .join(','); + final ins = route.inputs + .map((i) => '${i.portType.name}/${i.portName}') + .join(','); + debugPrint('chanora.route[$tag] out=[$outs] in=[$ins]'); + } catch (e) { + debugPrint('chanora.route[$tag] FAILED: $e'); + } + } + Future _selectSpeaker() async { + await _logRoute('speaker.before'); try { - // Apple-documented quirk: in .voiceChat mode, calling - // setPreferredInput(builtInMic) AFTER overrideOutputAudioPort(.speaker) - // causes iOS to recalculate the route. Built-in mic naturally - // pairs with the receiver (not the speaker), so the system - // SILENTLY REVERTS the speaker override and routes audio - // back through the earpiece. Net: the await chain returns - // successfully ('no exception'), but the user hears no - // change. - // - // Fix: do NOT call setPreferredInput when forcing speaker. - // The speaker override is sufficient on its own \u2014 input - // remains on whatever the system was already using (built-in - // mic by default, or BT/wired if connected and selected - // elsewhere). For consistency, only switch input when the - // user explicitly picks a non-speaker input row. await AVAudioSession() .overrideOutputAudioPort(AVAudioSessionPortOverride.speaker); - debugPrint('chanora: audio output -> speakerphone (override applied)'); + debugPrint('chanora: override(.speaker) returned OK'); } catch (e, st) { debugPrint('chanora: _selectSpeaker FAILED: $e\n$st'); } + await _logRoute('speaker.after'); + // Schedule a delayed re-check — if cpal's RemoteIO unit reverts + // the route on routeChangeNotification, this will surface the + // revert (out=builtInReceiver instead of builtInSpeaker). + Future.delayed(const Duration(milliseconds: 250), () { + _logRoute('speaker.after+250ms'); + }); if (!mounted) return; Navigator.of(context).pop(); } Future _selectReceiver() async { + await _logRoute('receiver.before'); try { - // Same quirk in reverse: removing the speaker override - // (.none) is enough to restore the .voiceChat default route, - // which is the built-in receiver. Calling setPreferredInput - // explicitly here is redundant and risks the same recalc - // race that broke _selectSpeaker before. await AVAudioSession() .overrideOutputAudioPort(AVAudioSessionPortOverride.none); - debugPrint('chanora: audio output -> receiver (override cleared)'); + debugPrint('chanora: override(.none) returned OK'); } catch (e, st) { debugPrint('chanora: _selectReceiver FAILED: $e\n$st'); } + await _logRoute('receiver.after'); + Future.delayed(const Duration(milliseconds: 250), () { + _logRoute('receiver.after+250ms'); + }); if (!mounted) return; Navigator.of(context).pop(); } Future _selectInput(AVAudioSessionPortDescription port) async { + await _logRoute('input.${port.portType.name}.before'); try { - // Drop any speakerphone override so the route follows the - // selected input. BT / wired headset / USB inputs pair their - // OWN output (the user hears audio through the same device - // they speak into), so .none + setPreferredInput is the - // correct combo here. await AVAudioSession() .overrideOutputAudioPort(AVAudioSessionPortOverride.none); await AVAudioSession().setPreferredInput(port); debugPrint( - 'chanora: audio output -> ${port.portName} (${port.portType})'); + 'chanora: setPreferredInput(${port.portName}/${port.portType.name}) OK'); } catch (e, st) { debugPrint('chanora: _selectInput FAILED: $e\n$st'); } + await _logRoute('input.${port.portType.name}.after'); + Future.delayed(const Duration(milliseconds: 250), () { + _logRoute('input.${port.portType.name}.after+250ms'); + }); if (!mounted) return; Navigator.of(context).pop(); }