From ace30fa6a175c378e5e163b74d5fa0976b480fb9 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:00:15 +0200 Subject: [PATCH 1/2] Restart microphone capture on audio device change When a capture device disappears mid-call (e.g. unplugging a Bluetooth headset), the local microphone went silent and never recovered: MicrophoneSource stayed bound to the gone device name and never re-registered the AudioProbe tap that Unity detaches when it rebuilds its audio graph. Subscribe to AudioSettings.OnAudioConfigurationChanged (mirroring the playback-side AudioStream handler) and restart capture on a device change, resolving to the OS default device when the preferred device is no longer present. Track the active device separately from the preferred one so Microphone.IsRecording/GetPosition/End target the right device, and guard against overlapping restarts. The native source's rate is fixed at construction, so if the device change moves Unity's DSP output rate, frames are still dropped; warn clearly in that case. Full rate-change recovery follows separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Scripts/MicrophoneSource.cs | 83 ++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index 34687f88..0841149f 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -14,12 +14,20 @@ namespace LiveKit sealed public class MicrophoneSource : RtcAudioSource { private readonly GameObject _sourceObject; + + // The device requested by the caller. Empty/null means "follow the OS default". private readonly string _deviceName; + // The device the microphone is actually recording from right now. This can differ from + // _deviceName when the preferred device is unavailable and we fall back to the OS default, + // so all Microphone.* calls (IsRecording/GetPosition/End) must use this name. + private string _activeDeviceName; + public override event Action AudioRead; private bool _disposed = false; private bool _started = false; + private bool _restarting = false; /// /// Creates a new microphone source for the given device. @@ -54,6 +62,10 @@ public override void Start() throw new InvalidOperationException("Microphone access not authorized"); MonoBehaviourContext.OnApplicationPauseEvent += OnApplicationPause; + // Restart capture when the system audio device changes (e.g. a Bluetooth headset is + // unplugged). Unity rebuilds its audio graph on a device change, which both detaches + // the AudioProbe tap and leaves Microphone.Start bound to a now-gone device. + AudioSettings.OnAudioConfigurationChanged += OnAudioConfigurationChanged; MonoBehaviourContext.RunCoroutine(StartMicrophone()); _started = true; @@ -75,11 +87,16 @@ private IEnumerator StartMicrophone() yield break; } + // Resolve which device to record from. Falls back to the OS default when the + // preferred device is gone, so an unplugged headset transparently hands off to the + // built-in microphone. + _activeDeviceName = ResolveCaptureDevice(); + AudioClip clip = null; try { clip = Microphone.Start( - _deviceName, + _activeDeviceName, loop: true, lengthSec: 1, frequency: (int)_expectedSampleRate @@ -123,20 +140,20 @@ private IEnumerator StartMicrophone() // Wait for microphone to actually start producing data with a timeout const float timeout = 2f; float elapsed = 0f; - while (Microphone.GetPosition(_deviceName) <= 0 && elapsed < timeout) + while (Microphone.GetPosition(_activeDeviceName) <= 0 && elapsed < timeout) { yield return new WaitForSeconds(0.05f); elapsed += 0.05f; } - if (Microphone.GetPosition(_deviceName) <= 0) + if (Microphone.GetPosition(_activeDeviceName) <= 0) { Utils.Error($"MicrophoneSource: Microphone did not start producing data after {timeout}s"); yield break; } source.Play(); - Utils.Debug($"MicrophoneSource device='{_deviceName}' started successfully"); + Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? ""}' started successfully"); } /// @@ -147,13 +164,14 @@ public override void Stop() base.Stop(); MonoBehaviourContext.RunCoroutine(StopMicrophone()); MonoBehaviourContext.OnApplicationPauseEvent -= OnApplicationPause; + AudioSettings.OnAudioConfigurationChanged -= OnAudioConfigurationChanged; _started = false; } private IEnumerator StopMicrophone() { - if (Microphone.IsRecording(_deviceName)) - Microphone.End(_deviceName); + if (Microphone.IsRecording(_activeDeviceName)) + Microphone.End(_activeDeviceName); // Check if GameObject is still valid before trying to access components if (_sourceObject != null) @@ -170,7 +188,7 @@ private IEnumerator StopMicrophone() UnityEngine.Object.Destroy(source); } - Utils.Debug($"MicrophoneSource device='{_deviceName}' stopped"); + Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? ""}' stopped"); yield return null; } @@ -197,8 +215,57 @@ private void OnApplicationPause(bool pause) } } + // Picks the device name to pass to Microphone.Start. An empty preferred name, or a + // preferred device that is no longer connected, resolves to null so Unity records from + // the current OS default device. + private string ResolveCaptureDevice() + { + if (string.IsNullOrEmpty(_deviceName)) + return null; + + if (Array.IndexOf(Microphone.devices, _deviceName) >= 0) + return _deviceName; + + Utils.Debug($"MicrophoneSource: preferred device '{_deviceName}' is no longer available, falling back to the OS default"); + return null; + } + + // Fires on the main thread when Unity's audio configuration changes, including when the + // system audio device changes (e.g. connecting/disconnecting a Bluetooth headset). Mirrors + // AudioStream.OnAudioConfigurationChanged on the playback side. + private void OnAudioConfigurationChanged(bool deviceWasChanged) + { + if (!_started) + return; + + // The native source's rate is fixed at construction and RtcAudioSource drops frames + // whose rate doesn't match it. If the device change moved Unity's DSP output rate, + // restarting capture alone won't recover audio — warn so the silence is diagnosable. + // Full recovery (recreating the native source at the new rate) is handled separately. + var outputSampleRate = (uint)AudioSettings.outputSampleRate; + if (outputSampleRate != _expectedSampleRate) + { + Utils.Warning($"MicrophoneSource: audio device change moved the DSP output rate to {outputSampleRate}Hz, but the native source is fixed at {_expectedSampleRate}Hz. Captured frames will be dropped until the track is recreated at the new rate."); + } + + // Unity rebuilds its audio graph on any configuration change — including an output + // route change (e.g. a Bluetooth headset disconnecting) where the input device itself + // doesn't change. On mobile the input is always the built-in mic regardless of the + // headset, so deviceWasChanged is false there even though the rebuild detaches the + // AudioProbe tap and stops capture. Always restart so the tap is re-registered; + // AudioStream does the same on the playback side and never gates on deviceWasChanged. + Utils.Debug("MicrophoneSource: audio configuration changed, restarting capture"); + MonoBehaviourContext.RunCoroutine(RestartMicrophone()); + } + private IEnumerator RestartMicrophone() { + // The device-change event can fire several times around a single hardware swap; + // ignore re-entrant restarts so overlapping Stop/Start coroutines don't race. + if (_restarting) + yield break; + _restarting = true; + yield return StopMicrophone(); // Wait for iOS audio session to be ready before attempting to restart. @@ -207,6 +274,8 @@ private IEnumerator RestartMicrophone() yield return WaitForMicrophoneReady(); yield return StartMicrophone(); + + _restarting = false; } private IEnumerator WaitForMicrophoneReady() From 4ce81f8b2f74debd89346668ee48649cc052f942 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:14:24 +0200 Subject: [PATCH 2/2] Adds diagnostic logs to debug failing mic switch on mobile --- Runtime/Scripts/MicrophoneSource.cs | 62 ++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index 0841149f..a774e8e9 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -29,6 +29,11 @@ sealed public class MicrophoneSource : RtcAudioSource private bool _started = false; private bool _restarting = false; + // Diagnostics: counts AudioProbe buffers delivered, so a read-only health monitor can tell + // whether capture has stalled (e.g. after a Bluetooth route change) without restarting. + private volatile int _audioReadFrames = 0; + private bool _monitoring = false; + /// /// Creates a new microphone source for the given device. /// @@ -69,6 +74,14 @@ public override void Start() MonoBehaviourContext.RunCoroutine(StartMicrophone()); _started = true; + + // DIAGNOSTIC (read-only): periodically log capture health so logcat shows whether + // buffers keep flowing and whether the config-changed event fires on a device change. + if (!_monitoring) + { + _monitoring = true; + MonoBehaviourContext.RunCoroutine(MonitorCaptureHealth()); + } } private IEnumerator StartMicrophone() @@ -153,7 +166,7 @@ private IEnumerator StartMicrophone() } source.Play(); - Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? ""}' started successfully"); + Utils.Info($"MicrophoneSource device='{_activeDeviceName ?? ""}' started successfully"); } /// @@ -188,12 +201,13 @@ private IEnumerator StopMicrophone() UnityEngine.Object.Destroy(source); } - Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? ""}' stopped"); + Utils.Info($"MicrophoneSource device='{_activeDeviceName ?? ""}' stopped"); yield return null; } private void OnAudioRead(float[] data, int channels, int sampleRate) { + _audioReadFrames++; AudioRead?.Invoke(data, channels, sampleRate); } @@ -235,6 +249,10 @@ private string ResolveCaptureDevice() // AudioStream.OnAudioConfigurationChanged on the playback side. private void OnAudioConfigurationChanged(bool deviceWasChanged) { + // DIAGNOSTIC: confirms whether this event fires at all on a device change (the open + // question on Android, where a Bluetooth route change may not change the DSP config). + Utils.Info($"MicrophoneSource: OnAudioConfigurationChanged deviceWasChanged={deviceWasChanged} outputSampleRate={AudioSettings.outputSampleRate} started={_started}"); + if (!_started) return; @@ -263,8 +281,12 @@ private IEnumerator RestartMicrophone() // The device-change event can fire several times around a single hardware swap; // ignore re-entrant restarts so overlapping Stop/Start coroutines don't race. if (_restarting) + { + Utils.Info("MicrophoneSource: restart requested but one is already in progress, ignoring"); yield break; + } _restarting = true; + Utils.Info("MicrophoneSource: restart begin"); yield return StopMicrophone(); @@ -276,6 +298,42 @@ private IEnumerator RestartMicrophone() yield return StartMicrophone(); _restarting = false; + Utils.Info("MicrophoneSource: restart end"); + } + + // DIAGNOSTIC (read-only — never restarts): logs capture health every couple of seconds so + // logcat shows whether AudioProbe buffers keep flowing after a device change and whether + // Microphone still reports recording/advancing. Runs for the lifetime of the source. + private IEnumerator MonitorCaptureHealth() + { + int lastFrames = _audioReadFrames; + int lastPosition = -1; + while (_started && !_disposed) + { + yield return new WaitForSeconds(2f); + + int frames = _audioReadFrames; + int delta = frames - lastFrames; + lastFrames = frames; + + bool recording = false; + int position = -1; + try + { + recording = Microphone.IsRecording(_activeDeviceName); + position = Microphone.GetPosition(_activeDeviceName); + } + catch (Exception e) + { + Utils.Warning($"MicrophoneSource: health probe threw {e.Message}"); + } + + Utils.Info($"MicrophoneSource: health framesLast2s={delta} totalFrames={frames} isRecording={recording} position={position} prevPosition={lastPosition} device='{_activeDeviceName ?? ""}' muted={Muted} restarting={_restarting}"); + lastPosition = position; + } + + _monitoring = false; + Utils.Info("MicrophoneSource: health monitor stopped"); } private IEnumerator WaitForMicrophoneReady()