@@ -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,54 @@ 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 capture/playback device changes (e.g. unplugging 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+ if ( deviceWasChanged )
252+ {
253+ Utils . Debug ( "MicrophoneSource: audio device changed, restarting capture on the current default device" ) ;
254+ MonoBehaviourContext . RunCoroutine ( RestartMicrophone ( ) ) ;
255+ }
256+ }
257+
200258 private IEnumerator RestartMicrophone ( )
201259 {
260+ // The device-change event can fire several times around a single hardware swap;
261+ // ignore re-entrant restarts so overlapping Stop/Start coroutines don't race.
262+ if ( _restarting )
263+ yield break ;
264+ _restarting = true ;
265+
202266 yield return StopMicrophone ( ) ;
203267
204268 // Wait for iOS audio session to be ready before attempting to restart.
@@ -207,6 +271,8 @@ private IEnumerator RestartMicrophone()
207271 yield return WaitForMicrophoneReady ( ) ;
208272
209273 yield return StartMicrophone ( ) ;
274+
275+ _restarting = false ;
210276 }
211277
212278 private IEnumerator WaitForMicrophoneReady ( )
0 commit comments