From 2f6cdd3ad490012388496c9102dfb1b3bd15c6e8 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sun, 17 May 2026 00:17:10 +0800 Subject: [PATCH] Revert "diag(ios,audio): log AVAudioSession state + route changes around picker overrides" This reverts commit da631a2bef9c9d2898d341ffd1efd87947b7f20e. --- .../ios/Runner/AppDelegate.swift | 72 ------------------- .../lib/widgets/voice_compact.dart | 69 ++++++++---------- 2 files changed, 28 insertions(+), 113 deletions(-) diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index 4bdcca1..6b464ac 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -85,20 +85,6 @@ 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 @@ -137,69 +123,11 @@ 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 1a59bc1..67cd5f0 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -766,78 +766,65 @@ 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: override(.speaker) returned OK'); + debugPrint('chanora: audio output -> speakerphone (override applied)'); } 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: override(.none) returned OK'); + debugPrint('chanora: audio output -> receiver (override cleared)'); } 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: setPreferredInput(${port.portName}/${port.portType.name}) OK'); + 'chanora: audio output -> ${port.portName} (${port.portType})'); } 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(); }