Skip to content

Commit ace30fa

Browse files
MaxHeimbrockclaude
andcommitted
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) <noreply@anthropic.com>
1 parent ba6c543 commit ace30fa

1 file changed

Lines changed: 76 additions & 7 deletions

File tree

Runtime/Scripts/MicrophoneSource.cs

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,20 @@ namespace LiveKit
1414
sealed public class MicrophoneSource : RtcAudioSource
1515
{
1616
private readonly GameObject _sourceObject;
17+
18+
// The device requested by the caller. Empty/null means "follow the OS default".
1719
private readonly string _deviceName;
1820

21+
// The device the microphone is actually recording from right now. This can differ from
22+
// _deviceName when the preferred device is unavailable and we fall back to the OS default,
23+
// so all Microphone.* calls (IsRecording/GetPosition/End) must use this name.
24+
private string _activeDeviceName;
25+
1926
public override event Action<float[], int, int> AudioRead;
2027

2128
private bool _disposed = false;
2229
private bool _started = false;
30+
private bool _restarting = false;
2331

2432
/// <summary>
2533
/// Creates a new microphone source for the given device.
@@ -54,6 +62,10 @@ public override void Start()
5462
throw new InvalidOperationException("Microphone access not authorized");
5563

5664
MonoBehaviourContext.OnApplicationPauseEvent += OnApplicationPause;
65+
// Restart capture when the system audio device changes (e.g. a Bluetooth headset is
66+
// unplugged). Unity rebuilds its audio graph on a device change, which both detaches
67+
// the AudioProbe tap and leaves Microphone.Start bound to a now-gone device.
68+
AudioSettings.OnAudioConfigurationChanged += OnAudioConfigurationChanged;
5769
MonoBehaviourContext.RunCoroutine(StartMicrophone());
5870

5971
_started = true;
@@ -75,11 +87,16 @@ private IEnumerator StartMicrophone()
7587
yield break;
7688
}
7789

90+
// Resolve which device to record from. Falls back to the OS default when the
91+
// preferred device is gone, so an unplugged headset transparently hands off to the
92+
// built-in microphone.
93+
_activeDeviceName = ResolveCaptureDevice();
94+
7895
AudioClip clip = null;
7996
try
8097
{
8198
clip = Microphone.Start(
82-
_deviceName,
99+
_activeDeviceName,
83100
loop: true,
84101
lengthSec: 1,
85102
frequency: (int)_expectedSampleRate
@@ -123,20 +140,20 @@ private IEnumerator StartMicrophone()
123140
// Wait for microphone to actually start producing data with a timeout
124141
const float timeout = 2f;
125142
float elapsed = 0f;
126-
while (Microphone.GetPosition(_deviceName) <= 0 && elapsed < timeout)
143+
while (Microphone.GetPosition(_activeDeviceName) <= 0 && elapsed < timeout)
127144
{
128145
yield return new WaitForSeconds(0.05f);
129146
elapsed += 0.05f;
130147
}
131148

132-
if (Microphone.GetPosition(_deviceName) <= 0)
149+
if (Microphone.GetPosition(_activeDeviceName) <= 0)
133150
{
134151
Utils.Error($"MicrophoneSource: Microphone did not start producing data after {timeout}s");
135152
yield break;
136153
}
137154

138155
source.Play();
139-
Utils.Debug($"MicrophoneSource device='{_deviceName}' started successfully");
156+
Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? "<default>"}' started successfully");
140157
}
141158

142159
/// <summary>
@@ -147,13 +164,14 @@ public override void Stop()
147164
base.Stop();
148165
MonoBehaviourContext.RunCoroutine(StopMicrophone());
149166
MonoBehaviourContext.OnApplicationPauseEvent -= OnApplicationPause;
167+
AudioSettings.OnAudioConfigurationChanged -= OnAudioConfigurationChanged;
150168
_started = false;
151169
}
152170

153171
private IEnumerator StopMicrophone()
154172
{
155-
if (Microphone.IsRecording(_deviceName))
156-
Microphone.End(_deviceName);
173+
if (Microphone.IsRecording(_activeDeviceName))
174+
Microphone.End(_activeDeviceName);
157175

158176
// Check if GameObject is still valid before trying to access components
159177
if (_sourceObject != null)
@@ -170,7 +188,7 @@ private IEnumerator StopMicrophone()
170188
UnityEngine.Object.Destroy(source);
171189
}
172190

173-
Utils.Debug($"MicrophoneSource device='{_deviceName}' stopped");
191+
Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? "<default>"}' stopped");
174192
yield return null;
175193
}
176194

@@ -197,8 +215,57 @@ private void OnApplicationPause(bool pause)
197215
}
198216
}
199217

218+
// Picks the device name to pass to Microphone.Start. An empty preferred name, or a
219+
// preferred device that is no longer connected, resolves to null so Unity records from
220+
// the current OS default device.
221+
private string ResolveCaptureDevice()
222+
{
223+
if (string.IsNullOrEmpty(_deviceName))
224+
return null;
225+
226+
if (Array.IndexOf(Microphone.devices, _deviceName) >= 0)
227+
return _deviceName;
228+
229+
Utils.Debug($"MicrophoneSource: preferred device '{_deviceName}' is no longer available, falling back to the OS default");
230+
return null;
231+
}
232+
233+
// Fires on the main thread when Unity's audio configuration changes, including when the
234+
// system audio device changes (e.g. connecting/disconnecting a Bluetooth headset). Mirrors
235+
// AudioStream.OnAudioConfigurationChanged on the playback side.
236+
private void OnAudioConfigurationChanged(bool deviceWasChanged)
237+
{
238+
if (!_started)
239+
return;
240+
241+
// The native source's rate is fixed at construction and RtcAudioSource drops frames
242+
// whose rate doesn't match it. If the device change moved Unity's DSP output rate,
243+
// restarting capture alone won't recover audio — warn so the silence is diagnosable.
244+
// Full recovery (recreating the native source at the new rate) is handled separately.
245+
var outputSampleRate = (uint)AudioSettings.outputSampleRate;
246+
if (outputSampleRate != _expectedSampleRate)
247+
{
248+
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.");
249+
}
250+
251+
// Unity rebuilds its audio graph on any configuration change — including an output
252+
// route change (e.g. a Bluetooth headset disconnecting) where the input device itself
253+
// doesn't change. On mobile the input is always the built-in mic regardless of the
254+
// headset, so deviceWasChanged is false there even though the rebuild detaches the
255+
// AudioProbe tap and stops capture. Always restart so the tap is re-registered;
256+
// AudioStream does the same on the playback side and never gates on deviceWasChanged.
257+
Utils.Debug("MicrophoneSource: audio configuration changed, restarting capture");
258+
MonoBehaviourContext.RunCoroutine(RestartMicrophone());
259+
}
260+
200261
private IEnumerator RestartMicrophone()
201262
{
263+
// The device-change event can fire several times around a single hardware swap;
264+
// ignore re-entrant restarts so overlapping Stop/Start coroutines don't race.
265+
if (_restarting)
266+
yield break;
267+
_restarting = true;
268+
202269
yield return StopMicrophone();
203270

204271
// Wait for iOS audio session to be ready before attempting to restart.
@@ -207,6 +274,8 @@ private IEnumerator RestartMicrophone()
207274
yield return WaitForMicrophoneReady();
208275

209276
yield return StartMicrophone();
277+
278+
_restarting = false;
210279
}
211280

212281
private IEnumerator WaitForMicrophoneReady()

0 commit comments

Comments
 (0)